What is Onion Architecture?
Onion Architecture places the domain model at the innermost ring. Surrounding it are use cases (the service layer), then the infrastructure adapters, and finally the presentation layer at the outermost edge. Each ring may only import from rings closer to the centre — the domain knows nothing about SQL, HTTP, or Effect services; the service layer knows nothing about SQLite or Bun.The Four Layers
Domain
Pure TypeScript types, schemas, and business rules. No Effect services, no I/O. Lives in
backend/src/domain.Service
Use cases and port interfaces. Defines what the application does without knowing how storage works. Lives in
backend/src/service.External / Adapters
Concrete implementations of each port — in-memory Maps for tests, SQLite via Effect SQL for production. Lives in
backend/src/external.Presentation
HTTP server, CLI, and MCP endpoints. Converts HTTP requests into service calls and service results into HTTP responses. Lives in
backend/src/presentation.How Effect v4 Enforces the Boundaries
Effect v4’sLayer system is the mechanism that wires the rings together at runtime while keeping them separate at compile time. A port is declared as a ServiceMap.Service in the service layer. The adapters layer provides a Layer value that satisfies that service. CoffeeOrderApp.layer composes these layers through Layer.effect, pulling in MenuRepository, OrderIdGenerator, and OrderRepository as constructor arguments — none of which it constructs itself.
Layer — either InMemoryCoffeeAppLive for tests or SqlCoffeeAppLive for production — and swaps the entire adapter stack in a single line.
Effect’s
Layer graph is constructed at startup. If a required service is missing the TypeScript compiler reports a type error before the program ever runs.Import Alias System
backend/package.json maps short aliases to layer directories using Node’s "imports" field. This prevents relative ../../ paths from bleeding across ring boundaries and makes the intended ring of every import statement obvious at a glance.
import type { CoffeeOrder } from "#domain/order" rather than "../../domain/order". An adapter imports a port with import { OrderRepository } from "#service/ports/OrderRepository". The alias prefix makes the dependency direction visible in every import statement.
Layer Composition at a Glance
Domain
Define
CoffeeOrder, MenuItem, OrderStatus, and all domain errors using effect/Schema. No service dependencies.Service ports
Declare
MenuRepository, OrderRepository, and OrderIdGenerator as ServiceMap.Service abstract contracts.Use cases
Implement
placeOrder, startBrewing, etc. as Effect.fn functions that yield* the port services without knowing the concrete adapter.Adapters
Provide
Layer values (InMemoryMenuRepositoryLive, SqlOrderRepositoryLive, …) that satisfy the port contracts.