FORM FOLLOWS DOMAIN. DOMAIN COMES FIRST.NODE.JS / REACT
PROJECT STRUCTURE / BUILDING BLOCKS

BUILDING
BLOCKS.

@domain-first/project-structure

What a scaffolded application is made of: bounded contexts, one shared layer, and the same five layers inside each of them.

src/bounded-contexts/
sales
✕
catalog
✕
billing
↓ every context may use the shared layer ↓
src/shared/
shared
  1. domain
  2. application
  3. infrastructure
  4. wiring
  5. presentation
ISOLATION IS ABSOLUTE

A bounded context never imports another one, not even a type. So two contexts may well have models with the same name, but a completely different set of fields and a different business meaning.

In billing, a User is who pays: their subscriptions and the tokens on their account. In identity, a User is who signs in: their email and when their session expires. Same name, two models, no conflict.

billing/domain/aggregates/userWho pays
export class User extends domainType(z.object({
    id: z.string(),
    subscriptions: z.array(z.string()),
    tokenBalance: z.number()
})) {}
identity/domain/aggregates/userWho signs in
export class User extends domainType(z.object({
    id: z.string(),
    email: z.email(),
    sessionExpiresAt: z.date()
})) {}
domain/

The domain holds the logic that defines the things the application deals with: what they are, which states are valid and which rules they follow. It knows nothing about databases, HTTP or the UI.

Domain only.

Broadly, the domain has four parts.

01Aggregates and repositoriesdomain/aggregates/

The main entities, each with its repository.

02Errorsdomain/errors/

Named business failures: which rule was broken.

03Outside aggregatesdomain/entities/domain/value-objects/

Entities and value objects shared by several aggregates, or owned by none.

04Domain servicesdomain/services/

Domain logic that no single aggregate owns.

ENTITIES AND VALUE OBJECTS

Two terms used below. A value object is defined entirely by its value and has no identity of its own: an amount of money, a JWT. Same value, same value object: one 10 USD is as good as another. An entity has a lifecycle: it changes over time but stays itself, usually thanks to an ID. A user who changes their name is still the same user.

Aggregates and repositories

An aggregate is an entity together with everything that only makes sense inside it: an order and its lines. Sometimes there is nothing else, and the aggregate is just a single entity. Its main entity, the aggregate root, is the single entry point: every operation on the aggregate goes through it, so it can make sure the data inside always stays consistent.

domain/aggregates/orderExample structure
order/
  order.aggregate-root.ts     Order, the root
  order-repository.ts         OrderRepository, the contract
  entities/
    order-line.entity.ts      OrderLine, only exists inside an order
  index.ts

Note that only the aggregate root has a repository. If every entity of an aggregate had its own, its parts could be changed separately, past the root, and the data could break rules the root exists to keep. Say an order must stay within its credit limit: an OrderLineRepository could add a line the order itself would never accept.

ONE ENTRY POINT

It follows from the above, but is worth saying out loud: work with an aggregate only through its root.

The repository is part of the domain layer, so it speaks the language of the domain. It loads and saves aggregates, or answers atomic questions about a set of them: does a user with this ID exist? That method belongs here.

user-repository.tsExample contract
export abstract class UserRepository {
    // works with the aggregate
    abstract findById(id: string): Promise<User | null>
    abstract save(user: User): Promise<void>

    // an atomic question about the set of aggregates
    abstract exists(id: string): Promise<boolean>

    // not here: pages and DTOs are an application concern
    // abstract listPage(page: number): Promise<UserListItemDto[]>
}
PAGES AND DTOS GO ELSEWHERE

A paginated list or a DTO shaped for one screen is not the repository's job. Solve it with an application command or query.

Errors

Good errors are worth about as much as a successful run. And many of them belong to the domain: they are the business rules saying “no”. Say we try to publish a project that is already published.

We could throw a string:

domain/aggregates/projectA string
throw new Error('Project is already published')

Or a proper error. Its code tells what happened, its details are a fixed, typed set, and it keeps its identity after a trip over REST.

domain/errors/index.tsA domain error
export const ProjectsErrors = errorNamespace('projects')

export class AlreadyPublished extends ProjectsErrors.define<
    { projectId: string }
>('already-published') {}

// in the aggregate
throw new AlreadyPublished({ projectId: this.id })
Response bodyAfter REST
{
    "code": "projects.already-published",
    "details": { "projectId": "p-42" },
    ...
}

On the other side, AlreadyPublished.matchesCode(body) recognizes it again. More in errors ↗.

THE UNHAPPY PATH HAS A SHAPE TOO

That is the point of domain errors: not only the happy path is predictable and structured, but also every case where the application can't finish the scenario.

Entities and value objects outside aggregates

An entity or value object often belongs to several aggregates at once: an Address is needed by both the order and the customer. Then it is moved up out of the aggregates, to domain/entities/ or domain/value-objects/. Less often it belongs to no aggregate at all but still matters to the domain, and it lives there too.

domainExample structure
aggregates/
  order/                      uses Address
  customer/                   uses Address
value-objects/
  address.value-object.ts     Address, shared by both

If several bounded contexts need it, it goes one level higher, into the shared layer.

Domain services

Some domain logic sits where several aggregates meet, and it is not clear which one should own it. Often it is a calculation by the rules of the domain that no single aggregate can answer alone. Such logic goes into a domain service.

Say a discount depends on the customer's previous orders and their loyalty history. It is not the order's business, and not the customer's alone either.

domain/services/discount-domain-service.tsExample
export class DiscountDomainService {
    discountFor(order: Order, customer: Customer, completedOrders: number): Discount {
        // loyalty tier, order history and the order itself decide the discount
    }
}

Most of the time a domain service works with data it is handed: it takes the aggregates and values it needs and applies the rules of the domain to them. Handing it that data is the application layer's job, and where the data comes from is not decided in the domain.

application/

What the application does: use cases, commands, queries, ports and application services.

Domain and application.

Broadly, the application has four parts.

01Ports

Contracts for capabilities from the outside world.

02Queries and commands

Reads and changes: atomic execution ones and orchestration ones built from them.

03Use cases

Complete things a user does that don't fit into a single query or command.

04Application services

Logic shared between use cases that doesn't fit into a single query or command.

Ports

The application talks to the outside world through ports and adapters. A port is a contract that gives your application some capability: a weather forecast, sending an email, taking a payment. It says what the application needs, not how it is done.

weather-forecast-provider.tsExample port
export abstract class WeatherForecastProvider {
    abstract forecastFor(city: string, date: Date): Promise<Forecast>
}

Ports have no common suffix. Each one is named by what it means in its own case: a WeatherForecastProvider, an EmailSender, a PaymentGateway, or just a Clock.

PORTS HERE, ADAPTERS IN INFRASTRUCTURE

Only the ports live in the application layer. Adapters, their concrete implementations, live in infrastructure.

Execution queries

An execution query is an atomic operation that reads data from any source and changes nothing in the system.

In spirit it is very close to a port: only the contract lives in the application layer. Its concrete implementations, say through Prisma, through an external API or over an in-memory store for tests, live in infrastructure.

Unlike ports, execution queries always end in Query: a UserBalanceQuery, a CustomerDetailsQuery, an OrderHistoryQuery.

An execution query exposes a single method, handle.

Execution commands

An execution command is built exactly like an execution query: a contract in the application layer, implementations in infrastructure, a single handle method. Only the suffix differs: Command, as in an UpdateUsernameCommand or a CreateUserCommand.

The difference is in what it is for. A query gets data and changes nothing. A command does the opposite: it changes the state of the system and returns either nothing or only the minimum needed to use its result later. Say a CreateUserCommand returns the ID of the user it created.

ONE OPERATION, NO CHAINS

Execution queries and commands are atomic, so they never use one another: no query inside a query, no command inside a command, and certainly no mixing of the two. All they may depend on is what it takes to do their own job, such as repositories and ports. That keeps each one small, testable on its own and replaceable without touching the others. Combining several of them is the job of orchestration commands and queries.

Orchestration queries and commands

In meaning, orchestration queries and commands are the same queries and commands. A query gets data and changes nothing in the system. A command changes the state of the system and returns either nothing or only what is needed to use its result.

The difference is in how they are built. An execution operation is a single atomic step: the application layer holds only its contract, and the implementation lives in infrastructure. An orchestration operation is made of several such steps, and all of its logic lives in the application layer. It takes the execution operations it needs and combines them into one scenario.

An orchestration query exposes a single method, handle, and an orchestration command exposes the same single method.

NOTHING IN INFRASTRUCTURE

Orchestration queries and commands have no infrastructure implementations. They don't touch technology themselves: they only coordinate execution operations, and those carry the technology.

What each one may use is not symmetric. An orchestration query may use only execution queries: reading stays reading. An orchestration command may use both execution commands and execution queries. A multi-step update often needs extra data along the way, and fetching it through a query is simpler and cleaner than passing everything in as parameters.

Queries and commands of both kinds may also use repositories.

NOT EVERYTHING CAN BE LINTED

A repository can both read and save. So, technically, nothing stops someone from plugging one into a query and calling save there. No linter will catch it. Please don't: some rules are still enforced by the head, not by the tooling.

Queries and commands vs repositories

At first glance this may look like extra work. Why write a separate query, with its own implementation, for a paginated list of users, if the same method could just go into the repository?

Just like calling save in a query, nobody forbids it. But the repository is part of the domain, and the domain describes only business rules: what would hold for any application built around it. Every other layer may depend on the domain, so the less often it changes, the better. That is why it should keep only what is most resistant to changes in the application.

Say tomorrow numbered pages turn into infinite scroll. The query changes, but the business rules don't.

KEEP THE DOMAIN STABLE

Logic tied to a particular screen, to an optimization, or to anything else not dictated by the subject area itself belongs entirely in the application layer. Don't let it into the domain.

STRAIGHT TO REST

Every query and command, execution or orchestration, is a handler from handlers ↗. So any of them can be used directly as the handler of a REST endpoint.

Use cases

At first glance, use cases may look odd: there are already queries and commands, and they can even be orchestration ones. Why one more thing? Because a use case solves its own problem. It describes one complete thing a user does that doesn't fit into a single query or command.

A use case lives entirely in the application layer. It has no infrastructure implementations: it takes the ports, queries and commands it needs and works with them. In that it is like orchestration queries and commands. Its name always ends in UseCase, as in a PlaceOrderUseCase or a RegisterUserUseCase.

NO PROXIES

Sometimes one query or one command is all a case needs. Then a use case that only passes the call on adds nothing. We stick to KISS: if something would exist only to proxy a call, don't write it, and keep the codebase clean. That is exactly why every query and command can be called straight from HTTP.

Say a user should be able to change their name in the profile, and you already have a ChangeUserProfileNameCommand (execution or orchestration, doesn't matter) that does exactly that. A ChangeUserProfileNameUseCase that only forwards the call to it makes no sense.

Use cases should appear in your code naturally. Once you see complex, important logic that doesn't fit into one query or one command, that is a good place for a use case.

Like orchestration queries and commands, a use case exposes a single method, handle, so it can be called over HTTP too.

Use cases are kept as isolated from each other as possible. A use case always describes business behavior, and two different use cases are two different use cases, even when their logic looks very much alike.

USE CASES NEVER IMPORT EACH OTHER

If several use cases share some logic, move it into an orchestration command or query, or into an application service, and call that from each of them. Reusable logic lives in orchestration commands, queries and application services, never in another use case.

Application services

An application service holds reusable logic that doesn't fit into one port, one query or one command: a complex orchestration of commands, queries and ports that is not a use case on its own.

Like a use case, it has no infrastructure implementations: all of its logic lives in the application layer.

Say several use cases share the same tricky sequence: a couple of queries, a command, a call to a port. Instead of copying it from one use case to another, move it into an application service with a method for it.

An application service also works well as a business-facing facade over a port. Say there is a JwtSigner port that signs and verifies JWTs. It knows only about JWTs: nothing about your user IDs or the shape of your app's token, and most likely it lives in the shared layer. Inside a bounded context, an AuthJwtApplicationService takes the JwtSigner as a dependency and adds what the context knows: what goes into your token and how to read it back.

auth-jwt-application-service.tsExample
export class AuthJwtApplicationService {
    constructor(private readonly jwtSigner: JwtSigner) {}

    issueFor(userId: UserId): Promise<string> {
        // builds the app's token payload and signs it
    }

    userIdFrom(token: string): Promise<UserId> {
        // verifies the token and reads the user ID back
    }
}
NOT A HANDLER

Unlike queries, commands and use cases, an application service exposes no handlers, so it can't be called over HTTP. Its only purpose is to be plugged into use cases and share logic between them.

infrastructure/

How it is done: repository and port implementations, command and query handlers, infrastructure services.

Domain, infrastructure, and from application only ports and execution contracts.

Broadly, the infrastructure has four parts.

01Repository implementations

Persistence behind the domain's repository contracts.

02Adapters

Implementations of the application's ports.

03Execution query and command implementations

The technology behind each atomic read and change.

04Infrastructure services

Technical tools shared by several implementations.

Implementations

Infrastructure is where contracts meet technology. Every repository from the domain, and every port, execution query and execution command from the application, gets its concrete implementation here: through Prisma, through an external API, or through anything else.

One contract may have several implementations. Each carries a label, such as prisma, api, in-memory or mock, that tells them apart. Wiring then picks one per environment: say the Prisma one in production and the in-memory one in tests. More in wire ↗.

SWAP THE TECHNOLOGY, KEEP THE CALLERS

Callers know only the contract. Move to another database, or replace a local implementation with a remote client when another context becomes a microservice, and nothing above infrastructure changes.

What implementations may use

An implementation does one technical job: it fulfils its contract. From the application it imports only ports and execution contracts, to implement them, and never takes application modules as dependencies: no use cases, no orchestration, no other queries or commands. A repository implementation doesn't use other repositories either.

COORDINATION GOES UP

If an implementation starts to need another query, command or repository, the logic it is growing belongs in the application layer, not here.

Infrastructure services

Some technical pieces are needed by several implementations at once: a configured database client, a client for one external API. They go into an infrastructure service. The Prisma 8 ↗ integration, for example, can generate a shared Prisma infrastructure service.

An infrastructure service is purely technical. There are no business rules in it, only the machinery the implementations run on.

wiring/

How it is assembled: a factory per module with its dependencies, picking an implementation per environment. Appears with @domain-first/wire.

Every layer, to assemble them.

THE SCAFFOLDER WRITES THEM

In the vast majority of cases you don't write wiring by hand. The scaffolder ↗ generates a wire for every module it creates, with the dependencies you picked and the layer rules already checked. You open this folder only when something unusual is needed.

Wires

Wiring is where all the pieces are put together. Classes in the other layers stay plain: they declare their dependencies as constructor parameters and know nothing about how they are built. For each module, wiring holds a wire: a zero-argument factory, () => T, that builds it with everything it needs.

wiringExample wire
const wirePublishProject = wireClass(PublishProjectUseCase, [
    wireProjectRepository,
])

The compiler checks every connection: a missing, misplaced or mistyped dependency won't compile. There is no container, no tokens and no decorators.

Implementations per environment

Wiring is also where one implementation is picked out of several: say the Prisma repository in production and the in-memory one in tests. Everything above it works with the contract and never knows which one runs.

wiringExample branch
const wireProjectRepository = byEnv({
    test: wireMemoryRepository,
    production: wirePrismaRepository,
})

More in wire ↗.

presentation/

How it is exposed: REST endpoints and presentation services.

Domain, application and presentation. Never infrastructure.

REST endpoints

An endpoint is a boundary around a handler. It describes only the HTTP side: the route, the request parts and how they map to the handler's input and back. Any query, command or use case can sit behind it, and the logic stays in the handler. More in handlers-rest ↗.

MOSTLY GENERATED

About 90% of an endpoint is generated by the scaffolder, wires included. All that is left to you is the mapping: how the REST request turns into the input of the handler you picked, and how its output turns back into the response.

NEVER INFRASTRUCTURE

The presentation works only with contracts. Which implementation actually runs behind them is decided by wiring, not here.

React

With React enabled, the scaffolder generates hooks, UI-kit components, pages and widgets into one folder: presentation/react at the root of the project, not inside the bounded contexts.

src/presentation/reactGenerated structure
hooks/
ui-kit/
pages/
widgets/

This is a deliberate choice. We assume there is one application and one React app on top of it, with one entry point. What is reused across the app goes into widgets and pages, not into a separate React tree per context.

GROUP WITH PATHS

To group pages or widgets, give them a slash-separated name such as admin/panel/users. The scaffolder nests the folders for you.

New widget → admin/panel/usersGenerated structure
src/presentation/react/widgets/admin/panel/users/
  admin-panel-users-widget.tsx
  index.ts

More in react ↗.