Project layout
Every Alveolus project has the same shape: the same folders, the same file names, the same direction between layers. Whoever opens the code, a teammate or an agent, knows where a concept lives before searching for it, and alveolus arch check keeps it that way.
Arrows read "depends on". They all point inwards: the domain depends on nothing but itself, so the business rules never change because a database, a framework or another context did.
At a glance
src/
main.ts # starts the application
app.module.ts # assembles the bounded contexts
ordering/ # a bounded context
ordering.module.ts # its composition root
domain/
aggregates/ # order.aggregate.ts
entities/ # order-line.entity.ts
value-objects/ # money.value-object.ts, order-id.identifier.ts
events/ # order-placed.event.ts
errors/ # invalid-total.error.ts
services/ # shipping-cost.service.ts
repositories/ # orders.repository.ts
ports/ # price-list.port.ts
views/ # order-summary.view.ts
application/
commands/ # place-order.command.ts
queries/ # get-order-summary.query.ts
translators/ # order-events.translator.ts
published-language/ # order-placed.representation.ts
driven/
pg/
adapters/ # pg-orders.adapter.ts
catalog/
adapters/ # catalog-price-list.adapter.ts
driving/
http/
controllers/ # orders.controller.ts
rabbitmq/
consumers/ # payment-received.consumer.ts
catalog/ # another bounded context, same shape
shared-kernel/ # shared by every bounded context, same shapeOnly create a folder when it gets its first file: a small context may have no entities/, services/ or driving/rabbitmq/.
Bounded contexts
A bounded context is a folder under src/, declared in alveolus.config.ts. Contexts may be nested, for instance under src/modules/. Each one has its own model: a Product in the catalog and a product in ordering are two different things, and neither imports the other.
A context is closed. The only class another context may import is an open host service, and only from an anti-corruption layer or from its composition root. What crosses the boundary is the published language: plain JSON that the reader redeclares on its side, never the classes of the other model.
The ordering domain asks for prices in its own words, through the PriceList port. The anti-corruption layer is the one place that knows the catalog exists: it calls CatalogApi, reads its JSON and answers with ordering's objects. If the catalog moves behind HTTP, only that adapter changes.
Layers
| Layer | Holds | May import |
|---|---|---|
domain/ | The model: aggregates, entities, value objects, events, errors, domain services, and the ports and repositories it needs. | The domain of its context and of the shared kernel, the domain building blocks of @alveolus/core, the packages listed in domainDependencies. No framework, no ORM. |
application/ | One class per use case: command handlers, query handlers, and the translators that turn domain events into the published language. | The domain, the application, its own published language, @alveolus/core, domainDependencies, applicationDependencies. No framework. |
published-language/ | The JSON types exchanged with other contexts: what this context publishes, and what it reads from the others. | Its own published language, the published-language types of core, and packages such as a schema library. |
driven/ | The adapters that implement the ports: database repositories, API clients, the outbox, the clock. | The domain, the application, the published language, any package. Never driving/. |
driving/ | The adapters that call the use cases: HTTP controllers, message consumers, scheduled jobs, CLI commands. | The domain, the application, the published language, any package. Never driven/. |
Inside driven/ and driving/, files always sit under the name of their technology: driven/pg/adapters/, driven/http/adapters/, driving/http/controllers/. Replacing a technology then means adding a folder next to the old one, never touching it. An adapter that calls another bounded context in the same process sits under the name of that context: driven/catalog/adapters/. Inside domain/ and application/, every class extends a building block of @alveolus/core: there are no free functions and no plain classes.
Folders and file names
Each class goes in the folder of its kind, in a file whose name ends with that kind. One class per file; the types that belong to it, such as its snapshot or its command input, stay in its file.
| Kind | Extends | Folder | File name |
|---|---|---|---|
| Aggregate | AggregateRoot | domain/aggregates/ | order.aggregate.ts |
| Entity | Entity | domain/entities/ | order-line.entity.ts |
| Value object | ValueObject | domain/value-objects/ | money.value-object.ts |
| Identifier | Identifier | domain/value-objects/ | order-id.identifier.ts |
| Domain event | DomainEvent | domain/events/ | order-placed.event.ts |
| Domain error | DomainError | domain/errors/ | invalid-total.error.ts |
| Domain service | DomainService | domain/services/ | shipping-cost.service.ts |
| Repository | CommandRepository, QueryRepository | domain/repositories/ | orders.repository.ts |
| Port | Port | domain/ports/ | price-list.port.ts |
| View | a View<…> type | domain/views/ | order-summary.view.ts |
| Command handler | CommandHandler | application/commands/ | place-order.command.ts |
| Query handler | QueryHandler | application/queries/ | get-order-summary.query.ts |
| Event translator | EventTranslator | application/translators/ | order-events.translator.ts |
| Representation | a PublishedLanguage<…> type | published-language/ | order-placed.representation.ts |
| Driven adapter | a port | driven/<technology>/adapters/ | pg-orders.adapter.ts |
| Open host service | implements OpenHostService | driving/<technology>/ | free |
Tests sit next to the code they test and keep its name: order.aggregate.spec.ts or order.aggregate.test.ts.
Composition root
Each bounded context has one file at its root that wires its adapters into its use cases, its module: ordering.module.ts. It is a class that builds everything with new, or the module of your framework's container, such as a NestJS @Module: see Integrations. It is the only file that sees every layer, and the only one, besides an anti-corruption layer, that may import from another context: another context's module, to reach its open host services.
At the root of src/, main.ts starts the application and app.module.ts builds or imports the module of each bounded context. These files import composition roots only.
Shared kernel
src/shared-kernel/ holds what every bounded context needs in the same form: value objects such as Money, ports such as a tracer, and their adapters. It has the same layers as a bounded context, possibly grouped by feature (shared-kernel/time/driven/system/adapters/). Every context may import it; it imports none of them.
Keep it small: each change to the shared kernel reaches every context. Clock and IdGenerator already come with @alveolus/core; only their adapters live here.
Who may import what
Read a row as "files in this layer may import…", within the same bounded context or from the shared kernel.
| From ↓ · To → | domain | application | published-language | driven | driving | composition root |
|---|---|---|---|---|---|---|
| domain | ✓ | ✕ | ✕ | ✕ | ✕ | ✕ |
| application | ✓ | ✓ | ✓ | ✕ | ✕ | ✕ |
| published-language | ✕ | ✕ | ✓ | ✕ | ✕ | ✕ |
| driven | ✓ | ✓ | ✓ | ✓ | ✕ | ✕ |
| driving | ✓ | ✓ | ✓ | ✕ | ✓ | ✕ |
| composition root | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
From another bounded context, only an open host service may be imported, by an anti-corruption layer or the composition root.
Checked for you
alveolus arch check verifies this layout on every run: see the rules, one page per rule, with what each one checks and why.