EMS Modularization & Internal Extension-Point Architecture
Author: Winston (System Architect)
Author: Winston (System Architect)
Date: 2026-05-29
Status: Draft for review β to be sharded into BMad epics/stories
Related: multi-tenant-saas-transformation.md (productization β deferred),
speaker-workflow-refactor.md (Epic 11), ADR-003, ADR-004, ADR-006, ADR-009
1. Context β why this work
The platform is live: two events have run successfully on it, and the team ships features weekly to
develop β staging (= production). Functionality is badly unevenly distributed:
- EMS is a ball-of-mud β ~400β760 Java files, 39 controllers, ~78 services, 99+ Flyway migrations (V107). It owns event CRUD + the 9-state event workflow, session management, the speaker-pool kanban + 8-state speaker workflow, participant/registration, newsletter, notifications, topics/AI, analytics, email templates, and media.
- The other backend services are hollow:
speaker-coordination-service(4 files β logic migrated into EMS during Epic 11),attendee-experience-service(1-file stub). - CUMS (users/companies) and partner-coordination are genuinely separate and out of scope for this work.
- The frontend is a static role-vertical:
navigationConfig.tsis a hand-curated array,App.tsxhard-codes ~69 routes,EventPagehard-codes 8 tabs, the admin page hard-codes 9. No registry, no dynamic contribution.
Every feature change pays a βfind-the-blast-radiusβ tax in EMS. Goal: convert EMSβs flat, layer-organized package tree into vertical domain modules with enforced boundaries and an internal extension-point model, so EMS becomes safe to change and optional features become toggleable β the foundation for eventual productization.
Scope decisions (locked with the product owner, 2026-05-29)
| Decision | Choice | Consequence |
|---|---|---|
| Deployment topology | Modularize EMS internally; leave topology as-is | Not a microservice split. Not a service-consolidation. One deployable per existing service stays. |
| Extension authors | This team only (internal) | Spring-native registry. No public/versioned plugin SDK, no OSGi classloader isolation. |
| Multi-tenancy | Seam only β no infra | One FeatureResolver interface + static config impl. No tenant_id columns. Productization is aspirational; no customer signed. |
Anti-goals (explicit β do NOT do these)
- β Split EMS (or anything) into more microservices.
- β Consolidate gateway + EMS + CUMS + partner into one app as part of this work (separate, deferrable infra effort, no feature payoff).
- β Build a public plugin SDK / external extension contract.
- β Build row-level multi-tenancy (
tenant_id, shared-DB isolation) for an unsigned customer (Rule of Three). - β Touch CUMS or partner-coordination internals (not the bottleneck).
- β Edit, renumber, or re-own the existing 99 EMS Flyway migrations (staging IS prod; checksums are frozen).
- β Big-bang: no long-lived βmodularization branchβ that re-slices everything then merges.
2. Target architecture (summary)
Backend β Spring Modulith + Spring-native extension registry
Re-package ch.batbern.events from layer-first to module-first, each first-level package a Spring Modulith
module exposing api/spi, hiding internal:
ch.batbern.events
ββ EventManagementApplication.java (@Modulithic root)
ββ eventcore/ {api,internal,web} package-info.java @ApplicationModule
ββ sessions/ {api,internal,web} (owns session_users β the speakerβsession join)
ββ speakerpool/ {api,spi,internal,web} (SpeakerProvisioningHook β spi/ as reference WorkflowHook)
ββ participants/ newsletter/ notifications/ topicsai/ analytics/ media/ email-templates/
| Module | Core/Opt | Owns (tables) | Published API | Emits / Consumes |
|---|---|---|---|---|
| event-core | CORE | events, event_types, app_settings, event_tasks, task_templates |
EventQuery, EventWorkflowApi |
emits EventCreated/WorkflowTransition |
| sessions | CORE | sessions, session_users, session_materials, timing/slot config |
SessionQuery, SessionAssignmentApi, TimetableApi, SlotAssignmentApi |
emits SessionTimingAssigned; consumes WorkflowTransition |
| speaker-pool | CORE (BATbern) / OPT (product) | speaker_pool, speaker_status_history, speaker_outreach_history, speaker_slot_preferences, session_content_history |
SpeakerWorkflowApi, SpeakerPoolQuery |
emits Speaker* events; consumes SessionTimingAssigned |
| participants | OPT | registrations (+ waitlist) |
RegistrationQuery, WaitlistApi |
emits RegistrationCreated/WaitlistPromoted; consumes EventCreated |
| newsletter | OPT | newsletter_subscribers/sends/recipients |
NewsletterApi |
consumes RegistrationCreated |
| notifications | CORE (pure consumer) | notifications |
NotificationChannel SPI |
consumes ~everything |
| topics-ai | OPT | topics, topic_usage_history, ai_prompts, ai_generation_log |
TopicQuery, AiAssistApi |
β |
| analytics | OPT | derived/read-model | AnalyticsApi |
consumes many (read) |
| media | OPT | event_photos, event_teaser_images, logos |
MediaApi, LogoApi |
β |
| email-templates | CORE (infra) | email_templates |
EmailTemplateApi |
inbound email |
Hardest coupling (migrate LAST): speaker-pool β sessions share session_users. SpeakerWorkflowService today
writes session_users directly via SessionUserRepository (and wires SpeakerPoolRepository + SessionRepository + EventRepository). Target: session_users belongs to sessions; speaker-pool calls
sessions.api.SessionAssignmentApi.assignSpeakerToSession(sessionSlug, username). Reverse direction via
SpeakerPoolQuery or the existing SpeakerWorkflowStateChangeEvent. SessionRepository is referenced by ~32
classes β migrate behind a progressively-tightened Modulith allow-list.
Enforcement: Spring Modulith primary (ApplicationModules.of(...).verify() β one CI test, static bytecode
analysis, no DB). One narrow ArchUnit rule for the ADR-003 boundary: no ..api.. class depends on another moduleβs
@Entity; cross-boundary references use meaningful IDs (eventCode, username, sessionSlug), never UUID FKs,
never cross-module FK constraints.
Extension registry (generalizes the existing SpeakerProvisioningHook): SPI interfaces +
List<T>/ObjectProvider<T> injection + @ConditionalOnProperty guards. Disabled module β its beans donβt exist β
contributes nothing. SPIs: WorkflowHook, TaskTemplateContributor, NotificationChannel, PublishingValidator,
AdminCapabilityDescriptor. A CapabilityRegistry bean powers the existing /api/v1/public/settings/features.
Feature toggle + tenancy seam: @ConfigurationProperties(prefix="batbern.modules") β
Map<String,ModuleConfig>{enabled} drives the guards; ModuleRegistry.isEnabled(moduleId) is the source of truth.
FeatureResolver{ boolean isEnabled(String moduleId, FeatureScope scope) } in shared-kernel; only
DeploymentFeatureResolver exists now (ignores scope). Future TenantFeatureResolver swaps in with zero call-site
changes. No tenant column anywhere yet.
DB / Flyway: one PostgreSQL DB, shared schema, ownership by code (tables already prefix naturally). Never move
V1βV107. New module migrations continue the global counter (V108+). Cross-module refs use meaningful IDs (mirror
ADR-003 at the module edge). Fix the known violation: local EventCreatedEvent extends DomainEvent<UUID> β shared
DomainEvent<String> (eventCode).
Frontend β feature-contribution registry (βEclipse RCP for webβ, internal)
A feature module = web-frontend/src/features/<name>/ exporting a FeatureModule manifest (id, enabledWhen,
i18nNamespaces, contributions {navItems, routes, eventTabs, adminTabs, settingsPanels}) referencing components via
React.lazy factories (no mass file moves up front).
- Registry (
src/features/registry.ts): plain typed object, static manifest imports (type-safety, tree-shaking, HMR; rejectimport.meta.glob). Pure collector functions, sorted byorder. No React dependency. - Gating (
src/features/gate.ts): one pureisVisible(rule, ctx)collapsing scattered role checks β shows iffanyRolematches ANDrequiresFeatureon ANDtenantAllows.GateContextfrom existinguseAuth()+useConfig();tenant: undefinedreserved. ExtendAppConfig.featuresadditively so old/api/v1/configpayloads default modules ON. Server stays the authority (ProtectedRoute+ backend@PreAuthorizeunchanged). - Hosts:
EventPage.tsx/EventManagementAdminPage.tsxrender registry tabs (lazySuspense, stableorder, string tab-ids + numericβid back-compat redirect for admin).App.tsxkeeps public routes static, folds module routes in.navigationConfig.tsconsumers render[...static, ...registry]deduped by path during migration.
3. Prod-safety doctrine (applies to EVERY step below)
This is a live system where staging IS production. Every increment must satisfy ALL of these before it ships:
- Additive-first. New code is added alongside old; old paths are deleted only after the new path is proven in a subsequent step. No step both adds and removes a behavior path in the same deploy unless the removal is provably inert.
- Flag-gated behavior changes. Any change that could alter runtime behavior ships behind a config flag defaulting to current behavior. Flip the flag in a later, separate deploy.
- Toggle-off == byte-identical. For each module slice, CI proves that with the module flag OFF the system behaves exactly as the pre-slice baseline (Playwright + Bruno diff).
- One PR = one deployable = one rollback unit. Reuse the existing
deploy-staging.ymlauto-rollback. No step depends on a not-yet-deployed step. - Flyway append-only. New migrations are forward-only (V108+). V1βV107 are frozen. Every new migration is idempotent/guarded and tested against a PostgreSQL clone (never H2) before deploy.
- Green main at every step. Modulith
verify(), ArchUnit, JUnit+Testcontainers, Vitest, Bruno, and Playwright all green. A step that canβt stay green is too big β split it. - Reversible by default. Prefer changes that can be reverted by a single
git revert+ redeploy. Event-driven decouplings use@TransactionalEventListener(AFTER_COMMIT)with the flag defaulting to the synchronous path until proven.
A step is βdoneβ only when it is deployed to prod and observed healthy, not when the PR merges.
4. Incremental roadmap β every step independently deployable & prod-safe
Notation per step: [Add] pure addition / [Flag] behind a default-off-behavior flag / [Cutover] removes an old path now proven inert. Each step is a PR. BMad epics group the steps.
EPIC 0 β Guardrails & Seams (no behavior change at all)
-
0.1 [Add] Spring Modulith dependency + baseline test (report-only). Add
spring-modulith-starter-testtoservices/event-management-service/build.gradle. AddModularityTeststhat callsApplicationModules.of(EventManagementApplication.class)but does NOTverify()yet β insteadDocumenterwrites the current (messy) dependency graph as a committed PlantUML baseline. Prod impact: none (test-only dep). Rollback: revert. -
0.2 [Add] ArchUnit dependency + one disabled rule. Add ArchUnit; write the ADR-003 boundary rule (
..api..must not depend on another moduleβs@Entity) but mark it@ArchIgnore/disabled with a TODO. Documents intent without failing CI. Prod impact: none. -
0.3 [Add]
FeatureResolverseam in shared-kernel. AddFeatureResolverinterface +FeatureScope(nullable tenantId/eventCode) +ModuleRegistry(@ConfigurationProperties(prefix="batbern.modules")) +DeploymentFeatureResolver(delegates to ModuleRegistry, ignores scope). Nothing calls it yet. Default config: all modules enabled. Prod impact: one inert bean. Rollback: revert. -
0.4 [Add] Frontend registry scaffolding with
MODULES = []. Addsrc/features/{types,gate,registry,useGateContext}.ts. Registry getters return[]. Add unit tests forgate.isVisibleand the empty registry. Nothing renders from it. Prod impact: dead code (tree-shaken). Rollback: revert. -
0.5 [Add] Dual-source the nav + tab hosts. In
navigationConfigconsumers (AppHeader/NavigationMenu/MobileDrawer),EventPage.tsx, andEventManagementAdminPage.tsx, render[...staticItems, ...registryItems]deduped by path/id. Registry is empty β output is byte-identical. Snapshot/RTL tests assert no change. Prod impact: none (registry empty). Rollback: revert.
Epic 0 exit: CI green, dependency graph published, seams in place, zero runtime change. Each of 0.1β0.5 ships to prod on its own.
EPIC 1 β Newsletter proof slice (the full pattern, end-to-end)
Newsletter chosen: self-contained (~32 EMS refs), organizer-only, low-traffic, non-critical on event day, touches all three frontend host types (nav + route + event-tab), and is a genuine optional/productization unit.
-
1.1 [Add] Create the
newslettermodule package, move classes, keep wiring identical. Move newsletter classes intoch.batbern.events.newsletter.{api,internal,web}+package-info.java@ApplicationModule. No call-graph change βRegistrationServicestill callsNewsletterSubscriberServicedirectly (now via the moduleβsapi). Add a@ApplicationModule(allowedDependencies=...)permissive enough to pass. Prod impact: package moves only, behavior identical. Verify: full EMS test suite + Bruno. Rollback: revert. -
1.2 [Add] Introduce
RegistrationCreatedEvent+ newsletter listener, alongside the direct call (dormant). Add the shared event;RegistrationServicenow both calls the direct path and publishes the event; the newsletter@TransactionalEventListener(AFTER_COMMIT)is present but guarded by a flag defaulting OFF, so it no-ops. Integration test asserts the listener fires when the flag is on. Prod impact: event published but listener inert. Rollback: revert. -
1.3 [Flag] Flip newsletter to the event path; direct call becomes conditional. Separate deploy: turn the listener flag ON; make the direct
RegistrationService β newslettercall conditional on the flag being OFF (so exactly one path runs). Observe prod (subscriber counts, SES sends) for a few days. Prod impact: behavior path swaps, but result is equivalent and reversible by flipping the flag back. Rollback: flip flag (no redeploy needed). -
1.4 [Cutover] Remove the direct call; enforce the Modulith rule for newsletter. Once 1.3 is proven healthy, delete the now-dead direct call, tighten the newsletter
@ApplicationModuledependencies, and enable the ArchUnit rule scoped tonewsletter. Prod impact: removes proven-inert code. Rollback: revert. -
1.5 [Add]
@ConditionalOnPropertyguard on newsletter beans + Bruno contract collection. Guard newsletter@Components with@ConditionalOnProperty(name="batbern.modules.newsletter.enabled", matchIfMissing=true). Add a Bruno collection pinned to the newsletter API. Add a CI job that boots EMS withnewsletter.enabled=falseand asserts the endpoints 404/disabled cleanly (no crash). Prod impact: none (default on). Rollback: revert. -
1.6 [Add] Frontend
newslettermanifest, dual-sourced. Addsrc/features/newsletter/manifest.ts(nav item + route + event-tab) and register it inMODULES. Because the hosts dual-source (0.5) and dedup by path/id, the static entries and the manifest entries collapse to one. Add thenewsletterfeature flag (default ON) additively toAppConfig.features. Prod impact: none (deduped). Verify: RTL host tests + Playwright. Rollback: revert. -
1.7 [Cutover] Remove the static newsletter entries. Delete the static nav item (
navigationConfig.ts), theEventPagenewslettertab entry, and the static route inApp.tsx. Now newsletter is sourced only from the registry. Prod impact: none (registry already provided them). Rollback: revert. -
1.8 [Flag] Prove toggle-off. Add a CI check: with
batbern.modules.newsletter.enabled=false(backend) andfeatures.newsletter=false(frontend), Playwright + Bruno show newsletter fully absent and everything else byte-identical to the Epic-0 baseline. This is the reusable template gate for every future slice. Prod impact: none (default on in prod).
Epic 1 exit: Newsletter is a fully self-contained, toggleable module end-to-end (backend + frontend + flag + contract test + toggle-off proof). The pattern is now boringly repeatable.
EPIC 2 β Generalize the extension registry
- 2.1 [Add] Move
SpeakerProvisioningHookβspeakerpool/spi/; define it as the referenceWorkflowHook. No behavior change (same bean, new package). - 2.2 [Add] Define
NotificationChannel,TaskTemplateContributor,PublishingValidator,AdminCapabilityDescriptorSPIs +CapabilityRegistrybean. Existing concrete behaviors register themselves; collection injection is additive. - 2.3 [Flag] Route
NotificationServicedispatch throughList<NotificationChannel>behind a flag; flip in a later deploy once parity is observed. - 2.4 [Add] Wire
CapabilityRegistryinto/api/v1/public/settings/featuresso the frontend menu/admin metadata reflects enabled modules. Default output unchanged.
EPIC 3 β Fan out, one module per story, in risk order
Each module repeats the Epic-1 template (package move β event-decouple if needed β flag guard β frontend manifest β cutover β toggle-off proof), each as its own set of independently-deployable steps:
- media (leaf, low coupling)
- topics-ai (leaf-ish, read-heavy)
- analytics (read-model consumer)
- participants (waitlist/auto-enroll chain β convert capacity reads to
EventQuery, newsletter coupling already cut in Epic 1) - notifications (listens to everything β only after the event-subscription pattern is proven on β₯3 modules)
- speaker-pool / sessions / event-core core-knot β LAST. The
session_users/SessionAssignmentApisurgery, migrating the ~32SessionRepositoryconsumers behind a progressively-tightened Modulith allow-list, one consumer group per PR.
Deferred indefinitely (not part of this effort)
- Monolith consolidation (infra-only, no feature payoff).
- Any tenancy beyond the
FeatureResolverseam β until a second customer signs (seemulti-tenant-saas-transformation.md).
5. Guardrails & success metrics
| Guardrail | Mechanism | When |
|---|---|---|
| Boundary verification | Spring Modulith verify() |
report-only Epic 0 β enforced per module from Epic 1 |
| ADR-003 module edge | One ArchUnit rule (meaningful-ID, no cross-module @Entity) |
disabled Epic 0 β enabled per module |
| Acyclic module graph | Modulith CI test fails on new cycles | baseline Epic 0, enforced Epic 1+ |
| Module API contracts | Bruno collection per module (extends existing domain-split suite) | per slice |
| Toggle-off = zero change | Flag off β Playwright + Bruno diff vs baseline | per slice (template from 1.8) |
| Flyway append-only | Forward-only V108+; V1βV107 frozen; PostgreSQL-clone test | always |
| Per-slice rollback | One PR = one rollback unit; deploy-staging.yml auto-rollback |
per slice |
Success metrics (BMad retro inputs): dependency graph acyclic (binary); EMS files-per-module balancing; files-touched-per-feature trending down; count of βpermanently-on-everywhereβ toggles (a toggle that never varies was speculative β delete it).
6. Critical files
Backend
services/event-management-service/src/main/java/ch/batbern/events/service/workflow/SpeakerProvisioningHook.java(+NoOpSpeakerProvisioningHook) β capability-interface + No-Op-default template to generalize.services/event-management-service/src/main/java/ch/batbern/events/service/RegistrationService.javaβ first event-driven decoupling cut (newsletter viaRegistrationCreatedEvent).services/event-management-service/src/main/java/ch/batbern/events/service/SpeakerWorkflowService.javaβ core-knot: removeSessionUserRepository/SessionRepository, callSessionAssignmentApi(last).shared-kernel/src/main/java/ch/batbern/shared/events/DomainEventPublisher.java(+EventBridgeEventPublisher) β the existing in-process event bus the strangler leans on.services/event-management-service/build.gradle(+ rootbuild.gradle) β Spring Modulith + ArchUnit, CI wiring.
Frontend
web-frontend/src/config/navigationConfig.tsβ replaced by registry-driven nav.web-frontend/src/App.tsxβ fold module routes in; keep public routes static.web-frontend/src/components/organizer/EventPage/EventPage.tsxβ hard-codedTABSβ registry tabs.web-frontend/src/pages/organizer/EventManagementAdminPage.tsxβ 9 hard-coded tabs β registry tabs (string ids).web-frontend/src/config/runtime-config.ts(+src/contexts/useFeature.ts,ConfigContext.tsx) β gating seam.- New:
web-frontend/src/features/{types,gate,registry,useGateContext}.ts+src/features/newsletter/manifest.ts.
7. Verification (per slice)
./gradlew :services:event-management-service:test 2>&1 | tee /tmp/ems-test.logβ Modulithverify()+ ArchUnit pass; integration tests green against Testcontainers PostgreSQL (never H2)../scripts/ci/run-bruno-tests.shβ module contract collection passes; zeroSkipping invalid filewarnings.cd web-frontend && npm run test 2>&1 | tee /tmp/fe-test.log && npm run type-check && npm run lint.- Toggle-off proof: set
batbern.modules.<slice>.enabled=false+ frontend flag off; Playwright (chromium/speaker/partner) + Bruno identical to pre-slice baseline. - New UI i18n keys present in all 10 locales; new module migrations forward-only (V108+), V1βV107 untouched.
- Deploy to prod; observe healthy (logs, SES/subscriber counts where relevant) before declaring the step done.