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/src/shared/- domain
- application
- infrastructure
- wiring
- presentation
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.
export class User extends domainType(z.object({
id: z.string(),
subscriptions: z.array(z.string()),
tokenBalance: z.number()
})) {}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.
domain/aggregates/The main entities, each with its repository.
domain/errors/Named business failures: which rule was broken.
domain/entities/domain/value-objects/Entities and value objects shared by several aggregates, or owned by none.
domain/services/Domain logic that no single aggregate owns.
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.
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.tsNote 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.
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.
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[]>
}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:
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.
export const ProjectsErrors = errorNamespace('projects')
export class AlreadyPublished extends ProjectsErrors.define<
{ projectId: string }
>('already-published') {}
// in the aggregate
throw new AlreadyPublished({ projectId: this.id }){
"code": "projects.already-published",
"details": { "projectId": "p-42" },
...
}On the other side, AlreadyPublished.matchesCode(body) recognizes it again. More in errors ↗.
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.
aggregates/
order/ uses Address
customer/ uses Address
value-objects/
address.value-object.ts Address, shared by bothIf 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.
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.
Contracts for capabilities from the outside world.
Reads and changes: atomic execution ones and orchestration ones built from them.
Complete things a user does that don't fit into a single query or command.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
}
}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.
Persistence behind the domain's repository contracts.
Implementations of the application's ports.
The technology behind each atomic read and change.
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 ↗.
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.
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.
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.
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.
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 ↗.
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.
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.
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.
To group pages or widgets, give them a slash-separated name such as admin/panel/users. The scaffolder nests the folders for you.
src/presentation/react/widgets/admin/panel/users/
admin-panel-users-widget.tsx
index.tsMore in react ↗.