Alpha — the platform is in development. Docs cover what ships today; anything unbuilt is marked. Concepts
-rw-rw-r-- content:content /docs/concepts/modules.dtmpl ● verified against v2.0

Modules & ports

A CoolMS feature is not a folder of related classes. It is a bounded context — a self-contained Symfony bundle with its own domain, its own use cases, and its own wiring to the outside world. Everything under src/ is one of these.

The point is not tidiness. It is that a context you can reason about alone is a context you can change alone, and later, move out on its own.

The shape of a module#

src/<Module>/
├── Domain/
│   ├── Entity/       aggregates and entities, carrying their own ORM mapping
│   ├── Repository/   *Interface contracts — ports, not implementations
│   ├── Service/      domain services and policies
│   └── Exception/
├── Application/
│   └── Service/      use-case orchestration over the Domain ports
└── Infrastructure/
    ├── ApiPlatform/  Resource + Provider (read) + Processor (write)
    ├── Doctrine/     repository implementations
    ├── Console/      commands
    └── DependencyInjection/  the bundle Extension

Domain holds the model and its ports. Application orchestrates use cases over those ports. Infrastructure connects them to the outside world.

One thing usually assumed here is not true, so it is worth stating plainly: the ORM mapping lives in Domain, not Infrastructure. Entities carry #[ORM\Entity] and their column attributes directly — 79 entity classes in Domain and none at all in Infrastructure, and not a single XML or YAML mapping file anywhere. Sixteen files under Infrastructure/ do mention ORM\Entity, which is what a first grep finds; every one of them is Doctrine query or mapping code, or a comment about this rule.

The rule that is actually enforced is narrower than "Domain knows nothing about Doctrine": Doctrine may be used only inside Infrastructure\Doctrine\, while Doctrine\ORM\Mapping is explicitly permitted in Domain. Mapping an entity beside itself is declaration; running a query beside it is not.

The boundary rule#

Modules carry a level. Core and Entity are the L0 shared kernel; feature modules sit above them. A module may depend downward, on something more foundational. It may not reach sideways into a peer's internals.

This is not a convention anyone has to remember. It is a custom static-analysis rule, and it fails the build:

Module boundary violation: Each module is a Bounded Context — depend on the target module's Domain *Interface, on Core/Entity contracts, or on lower-level modules only.

It is a ratchet, not a claim about the existing code. New code fails; code that predates the rule is listed in a baseline and keeps working — as of 2026-09-07 that baseline grandfathers 80 boundary violations across 21 modules. Saying modules "only ever" talk through published ports would be the kind of statement a reader disproves with one grep. What is true, and more useful, is that the number cannot grow: every violation there is a known debt, and the rule stops new ones being added while they are cheap to avoid.

So there are exactly three sanctioned ways to read data another module owns:

  1. Depend on its published port — a Domain\...Interface, never a concrete Infrastructure or Application class.
  2. Depend on an L0 contract both modules already share.
  3. Depend on a strictly lower-level module.

Two consequences come up constantly.

Narrow read-ports. Don't inject another module's aggregate repository to read a label. Define a small interface for exactly what you need and let the owning module implement it. A wide dependency you only use narrowly is still a wide dependency.

No cross-module foreign keys. References across contexts are soft refs: store the target UUID or slug as a plain column and resolve through a port. A schema validation will report the missing constraint — that drift is deliberate, and it is what keeps contexts independently deployable.

Adding a capability#

Most extension points are tagged-service registries, resolved lazily: element strategies, service-task handlers, option sources, field-widget providers, expression functions. You add a capability by implementing the interface and tagging the service.

You do not edit a central switch. If you find yourself wanting to, the registry is missing a seam and that is the thing to fix.

Warning

One trap worth knowing before it costs you an afternoon: a module's services are registered by a prototype scan that can replace a Definition wholesale, discarding explicit arguments an Extension set. Anything needing explicit args — tagged iterators, scalar parameters — belongs in a compiler pass, which runs after the scan, or is pinned on the consumer with an autowired iterator attribute. An Extension that competes with the scan loses silently, and the symptom is an argument that arrives empty rather than an error.