Skip to main content
The Effect Coffee Shop backend is organised around Onion Architecture — a layered style where business rules live at the centre and every outer ring depends inward, never outward. This keeps the domain free of framework concerns, makes adapters swappable without touching business logic, and gives every layer a single, well-defined reason to change.

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.
┌─────────────────────────────────────────┐
│           Presentation                  │  HTTP · CLI · MCP
│  ┌───────────────────────────────────┐  │
│  │         External / Adapters       │  │  SQL · In-Memory
│  │  ┌─────────────────────────────┐  │  │
│  │  │      Service Layer          │  │  │  Use Cases · Ports
│  │  │  ┌───────────────────────┐  │  │  │
│  │  │  │       Domain          │  │  │  │  Types · Rules · Errors
│  │  │  └───────────────────────┘  │  │  │
│  │  └─────────────────────────────┘  │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

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’s Layer 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.
export class CoffeeOrderApp extends ServiceMap.Service<
  CoffeeOrderApp,
  { /* method signatures */ }
>()("effect-v4-onion/service/CoffeeOrderApp") {
  static readonly layer = Layer.effect(
    this,
    Effect.gen(function* () {
      const menuRepository = yield* MenuRepository;
      const orderIdGenerator = yield* OrderIdGenerator;
      const orderRepository = yield* OrderRepository;
      // ...
    }),
  );
}
At the call site the presentation layer provides a composed 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.
{
  "imports": {
    "#domain/*": "./src/domain/*.ts",
    "#external/*": "./src/external/*.ts",
    "#presentation/*": "./src/presentation/*.ts",
    "#runtime/*": "./src/runtime/*.ts",
    "#service/*": "./src/service/*.ts"
  }
}
A use case imports domain types with 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.
The linter enforces that #domain/* is never imported from #external/* at the module level — the aliases are not just convenience, they are an enforced contract.

Layer Composition at a Glance

1

Domain

Define CoffeeOrder, MenuItem, OrderStatus, and all domain errors using effect/Schema. No service dependencies.
2

Service ports

Declare MenuRepository, OrderRepository, and OrderIdGenerator as ServiceMap.Service abstract contracts.
3

Use cases

Implement placeOrder, startBrewing, etc. as Effect.fn functions that yield* the port services without knowing the concrete adapter.
4

Adapters

Provide Layer values (InMemoryMenuRepositoryLive, SqlOrderRepositoryLive, …) that satisfy the port contracts.
5

Composition

live.ts merges adapter layers into InMemoryCoffeeAppLive or SqlCoffeeAppLive. The presentation layer picks one and passes it to Effect.runPromise.

Build docs developers (and LLMs) love