Skip to main content

Extensibility & city overrides

The platform ships default business logic (use cases), default REST controllers and default persistence in the published *-domain libraries. A city can replace or extend any of them — and add brand-new entities, use cases and endpoints — without editing platform code, so it can adopt new non-MAJOR platform versions and keep its customizations. This section explains the one override mechanism, then gives a library of worked examples, one per kind of customisation.

Start here, then copy an example

Read this page once to understand the seam model, then jump to the example below that matches what you want to change. Every example names the exact seam, the city files to add, whether a database migration is needed, and how to verify it.

1. How a city relates to the platform​

A city back-end is scaffolded from one of the two Maven archetypes and depends on the published libraries (io.github.unircs:*-domain). It owns no platform persistence or business source — only its application class(es), configuration, Flyway migrations and whatever overrides it chooses to add under the already-scanned com.modelcity.<vertical> base package.

Because the city's beans are component-scanned alongside the platform defaults, adding a class is all it takes: the platform default for that seam backs off automatically at startup and the city bean wins deterministically.

2. The override mechanism (one seam, one rule)​

Every overridable default is a normal Spring bean registered by component scanning: it carries its usual stereotype (@Service, @RestController, @Component) plus the platform marker @ModelCityDisabledIfInherited.

At startup, ModelCityDisabledIfInheritedProcessor — a BeanFactoryPostProcessor registered for every app by ModelCityExtensibilityAutoConfiguration in model-city-commons — runs after all bean definitions are known but before any bean is instantiated. For each annotated default it resolves the seam (the most derived @ModelCityExtensionPoint type in its hierarchy — a use-case interface, an abstract controller base or a store port) and, if another bean already covers that seam, removes the default's bean definition. The city bean therefore wins deterministically.

There is exactly one mechanism — no @Primary, no scan exclusions, no per-vertical auto-configuration catalogs. The @ModelCityDisabledIfInherited-annotated Default* classes are the greppable catalog of what a city may override:

# Every overridable platform default, across every vertical:
grep -rl "@ModelCityDisabledIfInherited" --include=*.java

# Every seam (the stable contracts a city may implement/extend):
grep -rl "@ModelCityExtensionPoint" --include=*.java

The four override doors​

The marker is deliberately not @Inherited, so a city bean opts in by covering the seam through any of the usual doors:

DoorWhen to useExample
implements the use-case interfacechange how one piece of business logic behavesclass MyGetEventsUseCase implements GetEventsUseCase<EventSummaryDto>
extends the abstract controlleroverride an endpoint or add new onesclass MyEventController extends EventController<…>
extends the Default*reuse the platform body and tweak one method (e.g. drop caching)class MyGetEventsUseCase extends DefaultGetEventsUseCase
implements the store portswap the persistence adapterclass MyEventStore implements EventStore<Event, EventRequestDto>

A brand-new use case, entity or endpoint that has no platform seam is even simpler: it is just a plain @Service / @Entity / @RestController under com.modelcity.<vertical>; there is no default to back off.

Naming convention​

  • The extension point keeps the plain name: a single-method interface (GetEventsUseCase) for a use case, an abstract base class (EventController) for a controller, or a port interface (EventStore) for persistence. This is the stable SPI; breaking it is a MAJOR change.
  • The platform default is Default<Name> (DefaultGetEventsUseCase, DefaultEventController, DefaultEventStore).
  • Defaults are city-neutral — no city name ever appears in a platform class (enforced by ArchUnit).

3. Anatomy of a vertical slice​

Every aggregate (events, city-places, sanctions…) follows the same hexagonal layering, and each layer is a seam a city can reach:

ConceptTypeLives inA city overrides it by…
Controllerabstract *Controller (@ModelCityExtensionPoint)*-domainextending it (@RestController)
Use casesingle-method *UseCase interface (@ModelCityExtensionPoint)*-domainimplementing it (@Service)
DTO*Dto / *RequestDto (plain extensible class)*-domainsubclassing it
View*View read-only interface*-domainextending it for extra read fields
Store / port*Store interface (@ModelCityExtensionPoint)*-domainimplementing it (@Component)
Entity + Repository*Base @MappedSuperclass + thin @Entity + @NoRepositoryBean generic repo*-domain / *-domain-{microservices,monolith}subclassing the base entity and binding the repo

Invariant persistence lives in the shared *-domain; the topology-divergent entities (the monolith's real @ManyToOne foreign keys vs the microservices' soft ids) live in *-domain-{microservices,monolith}. See Data model and Dual topology.

Caching/transactions live on the default

@Cacheable / @CacheEvict / @Transactional are declared on the platform default implementation. Annotations are not inherited across separate implementations, so a city that re-implements a use case and wants the same behaviour must re-declare them. Conversely, omitting @Cacheable in an override is exactly how you disable caching for one use case while the rest keep theirs — see Disable caching for a use case and Cache.

4. The contract, enforced by ArchUnit​

Each seam is annotated @ModelCityExtensionPoint (in commons). Shared ArchUnit rules (ExtensionPointRules, shipped from the commons test-jar and applied per vertical) enforce:

  • City-neutral defaults — no platform class carries a city name.
  • The marker only marks a real seam — allowed only on interfaces or abstract classes.
  • Overrides enter only through a seam — every Default* implements/extends a @ModelCityExtensionPoint type (a Default*Repository may instead bind a @NoRepositoryBean generic repository, the Spring-Data-native persistence seam).
  • Defaults are scanned and disableable — every concrete Default* carries a stereotype and @ModelCityDisabledIfInherited.
  • Single responsibility — a use-case seam exposes exactly one method.
  • Store ports are seams — every *Store interface carries the marker.

5. Versioning​

Use-case interfaces, store ports and controller endpoint signatures are the SPI. Changing them is a MAJOR release; adding new use cases / endpoints is MINOR. Cities should only need to react to MAJOR bumps.

Examples​

One worked recipe per kind of customisation. Each names the exact seam, the city files to add, whether a Flyway migration is needed, and how to verify it.

Data model & schema

Business logic (use cases)

Web layer (controllers & endpoints)

Persistence adapters

A whole new capability

Configuration (YAML)