Repository navigation
v0.9.0
Reliability and the production path. Projections converge when refreshes overlap, @Idempotent releases only its own registration, idempotent event consumers have a stable identity, and the four-layer rule keeps application services off infrastructure. Background work carries its tenant: projection marks, integration events, @Async tasks. Projections and event publications get operations: failure lists, retries, metrics and scheduled maintenance. ddk-mall recovers lost payments and has a secure profile. Upgrading from 0.8 takes a few steps, all listed in the upgrade guide.
Breaking
LAYERED_ARCHITECTURE_RULEno longer lets application services reference infrastructure classes. The four-layer documentation always said that infrastructure only implements domain ports; the rule now agrees, as the three-layer rule already did. Depend on the domain port instead. Wiring that has to name an implementation goes into a@Configurationclass outside the layers, which both layering rules now exemptProjectionsis now an interface; the implementation moved to the starter'sinternalpackage. Its public constructor tookinternaltypes, so no application could call it without breaking the internal-package rule. InjectingProjectionsis unchanged, a test can stub it without a mocking library, and an application bean of the type replaces the starter's
Added
DdkContainers.elasticsearch()inddk-test: a single-node Elasticsearch 9 without authentication, for integration testsddk-mallcan keep its order search read model in Elasticsearch (searchprofile). The projection, the API and the MCP tool are unchanged; only the implementation of theOrderSearchIndexport is swappedIntegrationEventConsumer.contract()andEventContract: a consumer declares the event type and versions it understands. Messages of another type are not given to it, and messages of another version are skipped with a warning, or rejected for redelivery, before their payload is decoded. Without a declaration nothing changes.ddk-mall's consumers declare their contracts- Projections in multi-tenant applications: a mark records the tenant of the current
OperatorContext, and the worker refreshes it as the system operatorddk-projectionin that tenant, also after a restart or on another instance. An ID that is only unique within a tenant no longer shares one mark across tenants. A mark without a tenant still fails againstddk-mybatis-starter's tenant interceptor and is retried, instead of reading every tenant's data - Tenants for integration events:
@IntegrationEvent(tenant = "...")names the accessor whose value is sent in theddk-event-tenantheader, and a consumer whoserunInEventTenant()returnstruehandles the event, including its inbox registration, as the system operatorddk-eventin that tenant. A message without the header fails instead of running without a tenant. Consumers that do not opt in are unchanged ddk-job-starterandddk-mcp-starterdocument how their entry points get an operator and tenant: a scheduled job runs each tenant throughOperatorContext.runAsas a system operator; an MCP tool on the default synchronous, streamable server sees the operator that the HTTP filter set, which a new test pins- Projection operations:
Projections.failures(name, limit)lists marks that keep failing with their last error, andProjections.retryFailed(name)retries them at once. With Micrometer present the starter publishesddk.projection.refresh(timer by outcome) and theddk.projection.pending,ddk.projection.failingandddk.projection.oldest.agegauges - Event maintenance (
ddk.event.maintenance.enabled, off by default): on a schedule, resubmits failed publications through Spring Modulith, deletes old completed publications and purges old inbox records, under a ShedLock lock when the application has aLockProvider. With Micrometer present,ddk.event.publications{status}counts publications by status ddk-mallrecovers payments whose outcome was lost: the attempt is committed before the channel is called, settlement runs in its own transaction and refunds when the order was cancelled meanwhile, andReconcilePaymentsJobqueries the channel for payments that were charged but never settled.PaymentChannelgainsquery; the simulated channel keeps an in-memory ledgerddk-mallhas asecureprofile: requests need a login, the customer comes from the authenticated user instead of theX-Customer-Idheader, rebuilds, stock changes and actuator are for the support role, and an MCP tool'scustomerIdargument must be the signed-in customer unless support is calling. The default profile stays an open development modeCommonArchRules.domainMustNotDependOn(String...): the domain purity rule with packages of your own added, such as an internal SDK
Changed
- The default scope of
@Idempotentand@RateLimitis now the full class name, method name and parameter types (com.acme.OrderService#place(PlaceOrderCommand)) instead ofOrderService.place, so classes with the same simple name and overloaded methods no longer share registrations or quotas. The Redis keys change accordingly: during a deployment, a request registered under the old key is not recognised as a duplicate by the new version for up to one TTL, and rate-limit windows start afresh. Setscopeexplicitly to keep a key stable scripts/ddk.shinstalls the latest release (vX.Y.Ztag) by default instead ofmain. SetDDK_REF=mainfor the development lineddk-projection-starterchanges its pending table: new columnstenant_id,generation,claim_tokenandclaimed_until, and a primary key that includes the tenant. Because the primary key changes, a 0.8 table is upgraded by copying it into the new structure; existing marks are kept and count as having no tenant. Withinitialize-schema=truethis happens at startup; withinitialize-schema=falsethe application now fails at startup, printing the statements, when the table is missing or has the 0.8 structure. Upgrade with the 0.8 instances stopped, or rebuild the projection afterwards. An upgrade that stops halfway leaves<table>_upgradebehind, and the next start finishes it from there instead of leaving its marks unprocessed. Invalid settings (batch size below 1, non-positive durations,retry-delayabovemax-retry-delay) also fail at startupddk-projection-startercreates an index onavailable_atfor the sweep that picks due marks. Withinitialize-schema=trueit is added to existing tables at startup; withinitialize-schema=falsea missing index is logged with the statement to run- An idempotent integration event consumer that Spring proxies (for example because
handleis@Transactional) is now recorded in the inbox under its own class name instead of the proxy's generated name. Events it handled before the upgrade and that are redelivered afterwards are handled again; overrideconsumerId()with the old name (Name$$SpringCGLIB$$0) if that matters during the upgrade
Fixed
OperatorContextdid not follow work onto other threads:@Asyncmethods and tasks on Spring Boot's executors ran without an operator, so in a multi-tenant application their first statement failed.ddk-corenow registers a Micrometer context-propagation accessor for it, so theContextPropagatingTaskDecoratorthatddk-tracer-starterregisters carries the operator, tenant included, and restores the thread afterwards@Idempotentreleased registrations by key only: a call that ran past the TTL and then failed deleted the registration of a newer call with the same key, letting a third one in. Each registration now stores its own token and is removed with a compare-and-delete. A failure to remove it no longer replaces the method's exception- Projections could keep a stale read-model entry with nothing left to correct it when two instances refreshed the same entry: a worker that read old state and finished last overwrote the newer state, and its acknowledgement removed nothing but also changed nothing. Marks also restarted at version 1 after deletion, so an old acknowledgement could delete a newer mark. Each mark now has a generation that is never reused and is claimed with a lease (
ddk.projection.lease-time); a worker whose acknowledgement misses marks the entry again, so the read model always converges - Idempotent integration event consumers in one group were told apart by their simple class name. Two classes with the same simple name shared one inbox identity, so the second skipped every event the first had handled.
IntegrationEventConsumer.consumerId()now names a consumer explicitly, and identities that clash within a group fail at startup. Consumers without a clash keep their identity, so existing inbox records stay valid scripts/ddk.shreused any earlier installation, so a changedDDK_REFor a newer commit on the same ref was never installed. It now records the ref, commit and version it installed in~/.ddk/installedand reuses an installation only when all three still match and the archetypes are in the local repositoryDOMAIN_MUST_NOT_DEPEND_ON_FRAMEWORKSdid not cover Jackson 3 (tools.jackson..), the Jackson that Spring Boot 4 uses, so domain classes could depend on it unnoticed- The four-layer template advised
AFTER_COMMITfor every domain event subscriber, including work that must happen with the business change. Theddk-add-domain-eventskill now chooses by purpose:@EventListenerinside the transaction for integration messages and read-model marks,AFTER_COMMITonly for notifications that may be lost, an integration event for work that must complete