10·03Project Structure

Folder names do not impose architecture; imports do. A codebase where any file may import any other becomes a tangle within months: HTTP handlers reach into SQL queries, domain types depend on the ORM, the test suite needs a real database to assert a business

53 - Project Structure: Dependency Direction Is the Rule

Previous · Next

Folder names do not impose architecture; imports do. A codebase where any file may import any other becomes a tangle within months: HTTP handlers reach into SQL queries, domain types depend on the ORM, the test suite needs a real database to assert a business rule.

Effect’s service system makes clean boundaries cheap, Context.Service for the interface, Layer for the implementation, but you still need a layout that makes those boundaries visible at a glance. This step pins the four-layer shape and the single rule that governs it.

What you’ll work through

index.test.ts contains three beats: the dependency rule (dependencies point inward only), the four-layer shape (domain / app / adapter / composition), and the composition root where every concrete Layer is assembled into the runtime.

By the end you should be able to glance at any import line in an Effect codebase and tell whether it crosses a boundary the wrong way.


The Rule

Dependencies flow inward. A layer may import from layers it contains; never from layers that contain it.

+----------------------------------------------------+
| entrypoints/ |
| wires services with Layer.provide |
| runs the program with runPromise / runFork |
| |
| +----------------------------------------------+ |
| | infrastructure/ | |
| | implements interfaces defined in domain | |
| | | |
| | +----------------------------------------+ | |
| | | application/ | | |
| | | orchestrates domain operations | | |
| | | | | |
| | | +----------------------------------+ | | |
| | | | domain/ | | | |
| | | | types, rules, service tags | | | |
| | | | zero external imports | | | |
| | | +----------------------------------+ | | |
| | +----------------------------------------+ | |
| +----------------------------------------------+ |
+----------------------------------------------------+

The Four Layers

domain/

Creates the vocabulary: types, validation rules, tagged errors, and the Context.Services that describe what services exist. No imports outside of effect itself.

domain/Order.ts
import { Context, Data, Effect, Schema } from "effect"
export const Order = Schema.Struct({
id: Schema.String,
total: Schema.Number,
})
export type Order = typeof Order.Type
export class OrderNotFound extends Data.TaggedError("OrderNotFound")<{
readonly id: string
}> {}
// ┌─── interface only, no implementation here
// ▼
export class OrderRepo extends Context.Service<OrderRepo, {
readonly find: (id: string) => Effect.Effect<Order, OrderNotFound>
}>()("OrderRepo") {}

Details

The service tag lives in domain/ so that application/ (which uses it) and infrastructure/ (which implements it) can both import it without either depending on the other.


application/

Orchestrates domain operations into use cases. Imports only from domain/.

application/PlaceOrder.ts
import { Effect } from "effect"
import { OrderRepo } from "../domain/Order.js"
// ┌─── Effect<Order, OrderNotFound, OrderRepo>
// ▼
export const placeOrder = (id: string) =>
Effect.gen(function* () {
const repo = yield* OrderRepo
return yield* repo.find(id)
})

When to Use

Anywhere a workflow composes multiple domain operations. Use cases stay free of transport details (HTTP status codes, CLI flags) and infrastructure (SQL, S3).


infrastructure/

Implements the Context.Services defined in domain/. Imports from domain/ and may also build on application/ helpers.

infrastructure/PgOrderRepo.ts
import { Effect, Layer } from "effect"
import { OrderRepo, OrderNotFound, type Order } from "../domain/Order.js"
export const PgOrderRepoLive = Layer.succeed(
OrderRepo,
OrderRepo.of({
find: (id) =>
Effect.tryPromise({
try: () => pgClient.query<Order>("SELECT * FROM orders WHERE id=$1", [id]),
catch: () => new OrderNotFound({ id }),
}),
}),
)

Details

Every adapter, Postgres, Stripe, S3, Redis, lives here. Swapping vendors becomes a single-file change because callers only ever know the tag.


entrypoints/

The composition root. Reads input, builds the layer graph, runs the effect.

entrypoints/http.ts
import { Effect, Layer } from "effect"
import { placeOrder } from "../application/PlaceOrder.js"
import { PgOrderRepoLive } from "../infrastructure/PgOrderRepo.js"
const MainLive = PgOrderRepoLive
Effect.runPromise(placeOrder("order-1").pipe(Effect.provide(MainLive)))

When to Use

There should be one entrypoint per delivery channel, http.ts, cli.ts, worker.ts. Each is small: parse input, provide the layer, run the effect.


What Goes Where: At a Glance

File Layer Allowed Imports
domain/Order.ts domain effect only
domain/OrderRepo.ts (tag) domain effect only
application/PlaceOrder.ts application effect, domain/*
infrastructure/PgRepo.ts infrastructure effect, domain/*, application/*, libs
entrypoints/http.ts entrypoint everything

A Composite Example

application/Checkout.ts
import { Effect } from "effect"
import { OrderRepo } from "../domain/Order.js"
import { PaymentService } from "../domain/Payment.js"
import { Notifier } from "../domain/Notifier.js"
// ┌─── Effect<void, OrderNotFound | PaymentFailed, OrderRepo | PaymentService | Notifier>
// ▼
export const checkout = (orderId: string) =>
Effect.gen(function* () {
const orders = yield* OrderRepo
const payment = yield* PaymentService
const notifier = yield* Notifier
const order = yield* orders.find(orderId)
yield* payment.charge(order)
yield* notifier.send(order.id)
})

The R channel is the project map: OrderRepo | PaymentService | Notifier. The entrypoint is the only place those three tags become concrete Layers.



A table-driven dependency validator

The four-layer rule is a policy. Writing it as a table — “from each layer, here are the layers it may import” — turns enforcement into a single lookup.

A function over the table is easier to audit than a chain of if-statements and easier to extend when a new layer appears.

import { Effect } from "effect"
type Layer = "domain" | "application" | "infrastructure" | "entrypoint"
const allowed: Record<Layer, ReadonlyArray<Layer>> = {
domain: [],
application: ["domain"],
infrastructure: ["domain"],
entrypoint: ["domain", "application", "infrastructure"],
}
const checkImport = (from: Layer, to: Layer): Effect.Effect<void, DependencyViolation> =>
allowed[from].includes(to)
? Effect.void
: Effect.fail(new DependencyViolation({ from, to }))

The table is the policy; the function is a one-line lookup. Adding a fifth layer is a row addition, not a refactor of the validator.

Your turn encode the four-layer policy as a table and write the validator as `allowed[from].includes(to)`. Run it against a known-good import graph and a known-bad one.

Common Pitfalls

1, Importing the implementation instead of the tag. Code in application/ should import OrderRepo (the tag) from domain/, never PgOrderRepo from infrastructure/. The tag is the seam; importing the implementation collapses it.

2, Putting Context.Service in infrastructure/. A tag defined alongside its Postgres implementation forces application/ to reach upward to import it. Move the tag down to domain/ and the dependency arrow flips back inward.

3, Business logic in entrypoints. An HTTP handler that loops, branches, and joins multiple services has stopped being a composition root. Lift the orchestration into application/ and leave the entrypoint as a three-line shim.

4, Skipping the layout for “small” projects. Once a project has more than one delivery channel (HTTP + cron, CLI + worker), the flat structure stops scaling. The four folders cost nothing to introduce on day one.


composition) in the sample project. - [ ] Inspect the composition root: where every concrete Layer is wired into the runtime.

See Also

  • Prerequisites, 21-context-basics, 22-layers, 23-service-driven.
  • Next, 54-incremental-adoption: applying this layout to an existing Promise codebase one slice at a time.
  • Related, 52-tsconfig: strict module resolution makes the import direction enforceable at compile time.

Run the tests: pnpm test 53-project-structure


The full test suite

The index.test.ts for this lesson, verbatim from steps/53-project-structure/.

index.test.ts
import { describe, expect, it } from "vite-plus/test"
// =============================================================================
// 29 - Project Structure: Dependency Direction Is the Rule
// =============================================================================
//
// Layered architecture is a constraint on imports, not on folder names.
// This table is the rule; the validator is a one-liner.
type ModuleKind = "domain" | "application" | "infrastructure" | "entrypoint"
const allowedDependencies: Record<ModuleKind, ReadonlyArray<ModuleKind>> = {
domain: [],
application: ["domain"],
infrastructure: ["domain", "application"],
entrypoint: ["domain", "application", "infrastructure"],
}
const validateDependency = (from: ModuleKind, to: ModuleKind): string | null =>
allowedDependencies[from].includes(to) ? null : `${from} must not depend on ${to}`
describe("29 - Project Structure", () => {
// @beat
// id: dependency-rule
// shape: one-todo
// title: Dependencies flow inward
// teaches: application may import domain; the validator encodes the rule as a table lookup.
// hint: an allowed dependency returns the same sentinel the validator uses for "no violation"
it("allows application code to depend on domain code", () => {
// Predict the sentinel a valid dependency yields.
expect(validateDependency("application", "domain")).toBe(/* @stub */ null /* @end */)
})
// @end-beat
// @beat
// id: four-layers
// shape: multi-todo
// title: Reject outward imports
// teaches: domain must never import infrastructure; application must never reach up to infrastructure.
// hint: the violation message follows the pattern `${from} must not depend on ${to}`
it("rejects infrastructure leaking into the domain", () => {
// Predict the violation message the validator returns.
expect(validateDependency("domain", "infrastructure")).toBe(
/* @stub */ "domain must not depend on infrastructure" /* @end */,
)
})
it("rejects application reaching up into infrastructure", () => {
expect(validateDependency("application", "infrastructure")).toBe(
/* @stub */ "application must not depend on infrastructure" /* @end */,
)
})
// @end-beat
// @beat
// id: composition-root
// shape: multi-todo
// title: Entrypoints compose every layer
// teaches: The entrypoint is the one place that touches domain, application, and infrastructure to assemble the layer graph.
// hint: an entrypoint sits on the outside and may depend on every inner layer — each call yields the same "no violation" sentinel
it("treats entrypoints as composition roots", () => {
// Predict the validator result for every entrypoint → inner-layer pairing.
expect(validateDependency("entrypoint", "infrastructure")).toBe(/* @stub */ null /* @end */)
expect(validateDependency("entrypoint", "application")).toBe(/* @stub */ null /* @end */)
expect(validateDependency("entrypoint", "domain")).toBe(/* @stub */ null /* @end */)
})
// @end-beat
// @beat
// id: write-validator
// shape: one-todo
// title: Write the dependency validator
// teaches: Table-driven validation is easier to audit than a chain of if-statements — the allowed imports table is the policy, the function is a single lookup.
// hint: allowedDependencies[from].includes(to) ? null : `${from} must not depend on ${to}`
it("your turn: implement validateDependency from the table", () => {
const myValidateDependency = (from: ModuleKind, to: ModuleKind): string | null =>
/* @stub */ allowedDependencies[from].includes(to)
? null
: `${from} must not depend on ${to}` /* @end */
expect(myValidateDependency("application", "domain")).toBe(null)
expect(myValidateDependency("domain", "application")).toBe(
"domain must not depend on application",
)
expect(myValidateDependency("infrastructure", "domain")).toBe(null)
expect(myValidateDependency("domain", "infrastructure")).toBe(
"domain must not depend on infrastructure",
)
expect(myValidateDependency("entrypoint", "infrastructure")).toBe(null)
})
// @end-beat
})
← PrevioustsconfigNext →Incremental Adoption