Behavioral Design Patterns
TechLearningPro's enrollment service priced a course, confirmed payment, issued a certificate, and sent a reminder from one class named 'EnrollmentManager'. The early-bird discount
Behavioral Design Patterns
Advanced System Design & Software Architecture · Lesson 17 · Low-Level Design and Design Patterns
Lesson Overview
TechLearningPro's enrollment service priced a course, confirmed payment, issued a certificate, and sent a reminder from one class named EnrollmentManager. The early-bird discount was an if on a date. The corporate-invoice discount was a second if on a learner attribute. A quiet-hours rule for email was a third if inside the method that also wrote the database row. When finance changed the early-bird window, the diff touched the payment confirmation path. When the certificate template changed, the reviewer had to re-read the pricing code. The class had one name and four reasons to change, which is the cohesion failure SOLID Principles in Practice names as a single-responsibility break.
Behavioral patterns are the object techniques for that kind of variation: an algorithm that must be replaceable, a fact that several collaborators must hear, a request that must be recorded and replayed, a fixed procedure with one replaceable step, and a lifecycle whose legal moves depend on where the object already is. This lesson develops five of them at working depth — Strategy, Observer, Command, Template Method, and State — and uses Chain of Responsibility once, as the gate list in a notification pipeline, because that pipeline is where Strategy and a chain meet in production. Iterator, Mediator, and Visitor are named at the end with the condition under which they earn a type, and they are not given a fake framework.
The examples are Java 21 and Spring Boot 3 on the enrollment path. Spring already contains several of these shapes. ApplicationEventPublisher and @TransactionalEventListener are the Observer you should reach for inside one process. TransactionTemplate.execute and JdbcTemplate.query are callback-style procedures. AbstractApplicationContext.refresh is an inheritance Template Method in the framework itself. Spring Framework does not ship a @Strategy or @State annotation, and this lesson does not invent one. A pricing policy is an interface you own. A state machine for enrollment is a sealed type you own. The separate Spring Statemachine project exists for persisted, guarded machines; adopting it is a dependency decision, recorded when five states and a unit test are no longer enough.
Hexagonal Architecture and Ports & Adapters still applies. A command handler is an application service. An event listener that sends mail is a driven adapter. The domain policy that decides a price does not import org.springframework. The patterns are how the objects collaborate. The ports are where those objects are allowed to stand.
Learning Objectives
By the end of this lesson you will be able to:
- Choose Strategy, Observer, Command, Template Method, or State from the kind of change you expect, and keep a straight-line method when that change is not real.
- Implement a pricing Strategy as a Spring-injected
Listof policies selected by a stable code, with an unknown code failing closed. - Publish an in-process enrollment event and consume it with
@TransactionalEventListenerso a listener runs after commit. - Model a confirm-payment Command as a value the handler can log and deduplicate, without building a command-bus framework Spring does not provide.
- Separate a true Template Method (
finalskeleton, overridable hook) from Spring's callback templates (JdbcTemplate,TransactionTemplate). - Encode enrollment transitions as a sealed State so an illegal move is a domain exception a test can force.
- Compose a notification pipeline from an ordered list of gates and a channel Strategy, and state what the pipeline does not guarantee once the process crashes.
- Tie each pattern back to open-closed and dependency inversion from lesson 4, and to the package rule from lessons 5 and 6.
Prerequisites
- Lesson 4, especially open-closed (a new discount should arrive as a new type) and dependency inversion (the enrollment policy depends on a pricing interface it owns). Lesson 5's dependency rule and lesson 6's driving versus driven adapters. A listener that sends mail sits outside the use case.
- Java 21: records, sealed interfaces,
List, and constructors. You will read@Component, constructor injection,ApplicationEventPublisher,@EventListener, and@TransactionalEventListener. You do not need Spring Statemachine on the classpath. - The enrollment vocabulary from lesson 4: a quote has a list price, a learner, and a policy code. Money is a value object with a currency. This lesson does not re-derive pricing arithmetic; it places the arithmetic behind a policy.
Detailed Core Concepts
Variation in behavior is a different problem from variation in structure
Creational patterns answer how an object is built. Structural patterns answer how objects are composed into a larger shape. Behavioral patterns answer how responsibility moves while the program runs: who decides the next calculation, who is told that something happened, who is allowed to act given the current mode. The Gang of Four catalog is a vocabulary for collaborations that kept reappearing in object systems. It is not a checklist. A design that uses all five patterns on a three-field form has paid for flexibility nobody requested. Lesson 3's rule still holds: a hypothetical variation is not a driver.
The decision this lesson uses is empirical. Name the change. If the change is "a new way to compute the same result, selected per request," reach for Strategy. If the change is "another party must react, and the writer of the fact should not grow a dependency on that party," reach for Observer. If the change is "this request must be named, stored, or retried as a unit," reach for Command. If the change is "the steps stay in one order and one step differs by subtype," reach for Template Method. If the change is "the methods that are legal depend on the object's history," reach for State. If you cannot name the change, leave the code as a method.
Strategy
Strategy replaces a conditional algorithm with a family of interchangeable objects behind one interface. The context holds a PricingPolicy and asks it for a price. The context does not contain the early-bird calendar. Each policy is closed for modification and the set of policies is open for extension, which is the open-closed principle applied to a calculation.
The interface belongs to the caller that needs the calculation, the same way a port belongs to the use case in lesson 6. PricingPolicy lives next to the quote. EarlyBirdPricing and CorporateInvoicePricing implement it. A selector maps a stable string code, stored on the quote or supplied by the client, to one policy. The code is data. The class is the behavior. Mixing them — a policy that also parses HTTP — pulls the adapter inward.
Spring will inject List<PricingPolicy> when every implementation is a bean. That is convenient and it is also a scope trap. A policy bean is a singleton by default. It may hold collaborators that are themselves singletons: a clock, a configuration window, a repository. It must not hold the learner id, the quote, or any other per-request field. Per-request data arrives as a method argument. A strategy that stores request state in fields is a race between concurrent enrollments, and the tests that call it on one thread will stay green.
Strategy is the wrong tool when there is one algorithm and no second implementation on the roadmap that a stakeholder has named. An interface with one class and a selector map is a ceremony. It becomes justified on the day the second policy ships, or on the day the test needs to substitute a fixed price without a calendar. Write that condition down. Do not extract the interface "because pricing might change."
Observer
Observer separates the occurrence of a fact from the parties that react. The subject publishes "enrollment completed." Certificate issuance, analytics, and a welcome notice subscribe. The subject does not import those three collaborators. In the classic drawing the subject keeps a list and calls each observer. In a Spring application the practical subject is ApplicationEventPublisher. You pass an object, often a record, to publishEvent. Methods annotated @EventListener or @TransactionalEventListener receive it if the parameter type matches.
This is in-process. The publisher does not write a topic, does not retry across a crash, and does not promise an order across threads unless you configure the multicaster. Default delivery on publishEvent is synchronous: listeners run on the publisher's thread, and an exception from a plain @EventListener can fail the caller. That coupling surprises teams who heard "events" and pictured a broker. Say "in-process notification" until you have an outbox and a topic.
@TransactionalEventListener changes the moment of delivery. The default phase is TransactionPhase.AFTER_COMMIT. The listener runs after the transaction that published the event has committed. A certificate listener can no longer see a row that the transaction then rolls back, which is the bug you get when a listener sends mail inside the transaction and the transaction fails afterward, or when it sends mail for a row that never landed. The matching limitation is the one you must design for: after commit, a listener exception does not undo the enrollment. The learner is enrolled and the certificate was not issued. The listener's work needs its own recovery: a row that a job can scan, or an outbox written in the same transaction as the enrollment. The event annotation does not provide that row.
A second Spring behavior is easy to miss. A @TransactionalEventListener method does not run when no transaction is active, unless fallbackExecution is true. A service method that publishes and is not @Transactional will look as if the listener is broken. The listener is behaving as documented. The publisher forgot the transaction.
java.util.Observable is deprecated and is not the tool. Do not revive it to avoid Spring. If the domain must not import Spring, the use case depends on a small EnrollmentEvents interface you define, and a Spring adapter implements it by calling ApplicationEventPublisher. That is lesson 6 with a one-method port. Publishing ApplicationEventPublisher into a domain entity imports the framework inward and fails the package test from lesson 5.
Command
A Command is a request reified as an object. The classic roles are the command, the invoker that calls it, and the receiver that knows how to do the work. Undo is optional. Most business commands in this course are not undoable; a payment confirmation is compensated by a refund command, which is a different request with its own rules, not undo() on the original object.
In the enrollment service the command is a record: ConfirmPayment(enrollmentId, idempotencyKey). The handler is a class with handle(ConfirmPayment). The controller, a driving adapter, builds the record and calls the handler. The value of the object is that the request has a type, a place to validate, and a shape you can append to an audit log before the work runs. The handler can refuse a second ConfirmPayment with the same idempotency key by reading a store, which is the same idea lesson 5 used for authorization, applied here to a behavior object.
Spring does not include a command bus. Introducing a library, or writing a bus that finds handlers by reflection, is a choice with operational cost: failure routing, timeouts, and "which handler ran" become framework questions. For a handful of enrollment commands, the controller calling the handler is the design. A map from command class to handler is justified when one driving adapter must accept several command types and you want one dispatch point. It is still your map, written in the bootstrap or adapter package, not a Spring feature you switch on.
The anti-pattern is a single EnrollmentCommandService with a switch on a string type field. You have then rebuilt the original conditional inside the pattern's costume. One handler per command type keeps the reason to change local. The command record stays free of Spring. The handler may be a @Service because it is the application service, which lesson 5 already allowed at the edge of the use case if you are explicit about transactions. Prefer the handler as plain Java when you can pass the store and the clock in the constructor, and let a thin @Service own @Transactional. That split is lesson 5's outer action, reused so this lesson does not invent a second transaction doctrine.
Template Method
Template Method fixes the order of steps in one type and lets subtypes replace designated steps. The skeleton method is final so a subtype cannot reorder the steps and quietly skip the audit write. Hooks are protected and abstract when every subtype must supply them, or concrete with an empty or default body when the hook is optional. The enrollment notice sender in this lesson validates, delivers, then records the attempt. Validation and the log write do not vary. Delivery does: email versus an in-app inbox.
Inheritance is the mechanism, and inheritance is the cost. A template hierarchy is hard to test at the hook without constructing the subtype, and a new combination of hooks becomes a new subclass. When the varying part is a single collaborator, Strategy is the better fit: the skeleton stays one class and receives a Channel in the constructor. Use Template Method when the superclass owns an invariant about order — "never record a delivery we did not attempt, never attempt a blank body" — and the number of subtypes is small and stable. Use Strategy when the variants are numerous or chosen at runtime.
Spring's JdbcTemplate.query and TransactionTemplate.execute keep boilerplate in the template and take a callback for the varying work. That is a Strategy callback. The Gang of Four Template Method uses inheritance. AbstractApplicationContext.refresh, with hooks such as onRefresh, is the inheritance shape inside the framework. You use it by writing a Spring Boot application. You do not copy it into enrollment code. TransactionTemplate is appropriate when a non-bean already has a PlatformTransactionManager and must run a short database callback. A callback that performs HTTP holds the connection for the remote call. Keep that call outside execute, as lessons 5 and 6 require.
State
State represents a lifecycle in which behavior depends on the mode the object is already in. An enrollment can be draft, awaiting payment, active, or cancelled. confirmPayment is legal in awaiting payment. It is illegal in draft, because a draft has not been submitted, and illegal in active, because payment was already confirmed. Encoding that as if (status.equals("AWAITING")) in every method spreads one rule across the class and invites a new status string that nobody handles.
The pattern gives each mode its own type. The context, Enrollment, holds an EnrollmentState and forwards confirmPayment and cancel to it. Each state returns the next state or throws a domain exception. With Java sealed interfaces the compiler lists the permitted states, so a new status cannot appear as an unhandled string. The exception carries no HTTP status. The web adapter maps it to 409, as lesson 5 mapped a refused capture.
State and Strategy share a structure: the context delegates to an interchangeable object. The intent differs. A Strategy is chosen by the caller to plug in an algorithm, and it usually does not know about its siblings. A State is chosen by the lifecycle, and its main job is to decide the successor state and to refuse the calls that this mode does not allow. If the "state" object is selected once from configuration and never transitions, you have a Strategy. Name it as a Strategy so the next reader does not look for a transition that will never come.
A stringly status with a switch can be the right design when there are three statuses, one method, and no behavior difference beyond a guard. The pattern pays for itself when each state owns different behavior, or when illegal transitions are a defect you have already shipped. Do not introduce a class per status on the first day for a boolean cancelled.
Spring Framework has no state type for your aggregates. Spring Statemachine, a separate project, will persist states, run guards, and broadcast its own events. It is appropriate when the machine is large, the transitions are configured by people who are not editing the aggregate, or you need a persisted machine across processes. For four enrollment states, a sealed interface tested without Spring is smaller and the rules stay in the domain package. Record the reopen condition: "more than a handful of states, or transitions that must be edited without a release of the aggregate."
How the five collaborate on one use case
A realistic enrollment completion uses more than one pattern, each for one reason:
- State decides whether
confirmPaymentis legal and moves awaiting payment to active. - Command is the
ConfirmPaymentvalue the handler accepts, logs, and deduplicates by idempotency key. - Strategy prices the quote that the command refers to, if a repricing is part of confirmation. Often the price was fixed at submit time and the command only confirms. Do not re-run a calendar-sensitive Strategy at confirmation if the quote was already accepted; that is how an early-bird window closing overnight changes a price the learner already saw. Snapshot the money on the enrollment when the quote is accepted.
- Observer publishes
EnrollmentCompletedafter the state is durable, and listeners issue the certificate and the notice. - Template Method, or a Strategy channel under a fixed pipeline, sends the notice. The pipeline's gates are the chain in the enterprise scenario below.
If a design review cannot point to a different change-driver for each pattern, delete a pattern. The goal is a class per reason to change, which is lesson 4, not a class per chapter of a patterns book.
Patterns this lesson will not pretend to implement
Iterator is already the JDK's Iterator. A custom enrollment iterator over an in-memory list adds nothing; paging belongs to the repository in lesson 18. Mediator is a coordinator with a business name. A @Service that calls three ports is an application service, and renaming it Mediator does not split its reasons to change. Visitor fits a stable hierarchy with many operations, such as a document model. Enrollment changes often and has few operations, so behavior stays on the types. Chain of Responsibility is the notification gate list later in this lesson: an ordered List the pipeline owns, not a next pointer inside each gate. Servlet filters are the framework example you already run. @Order is how Spring sorts that list when component scan builds it. A linked chain is harder to see and easier to cycle.
Architecture and Design Diagrams
The enrollment completion path keeps domain types free of Spring. The publisher port is the seam.
mermaidflowchart TBsubgraph adapter [Adapters]http[ConfirmPaymentController]pub[SpringEnrollmentEvents]cert[CertificateListener]notice[NoticePipelineListener]endsubgraph app [Application]handler[ConfirmPaymentHandler]events[EnrollmentEvents]endsubgraph domain [Domain]enrollment[Enrollment]state[EnrollmentState]policy[PricingPolicy]endhttp --> handlerhandler --> enrollmentenrollment --> statehandler --> policyhandler --> eventspub --> eventspub --> certpub --> notice
ConfirmPaymentHandler depends on EnrollmentEvents, an interface in the application package. SpringEnrollmentEvents implements it and is the only type here that calls ApplicationEventPublisher. Listeners are beans the container registers. They are not constructor dependencies of the handler, which is the point of the observer: the handler does not grow a parameter when a new listener appears. The cost is discovery. A reader cannot see the certificate side effect from the handler's constructor. The event record's name and the listener's package have to make that side effect findable. A package adapter.enrollment.listeners with one listener per file is the convention that keeps the search short.
State transitions for the aggregate:
mermaidstateDiagram-v2[*] --> DraftDraft --> AwaitingPayment: submitAwaitingPayment --> Active: confirmPaymentDraft --> Cancelled: cancelAwaitingPayment --> Cancelled: cancelActive --> Cancelled: cancel
confirmPayment from Draft has no arrow. The state type throws. Active to Cancelled is included because a learner may withdraw; the command for that transition is CancelEnrollment, not a boolean on ConfirmPayment. Cancellation of an active enrollment may require a refund policy. That policy is a Strategy invoked by the cancel handler. It is not a method on Draft.
Delivery of the completion event relative to the database transaction:
mermaidsequenceDiagramparticipant Clientparticipant Handler as ConfirmPaymentHandlerparticipant Store as EnrollmentStoreparticipant Events as EnrollmentEventsparticipant TX as Transactionparticipant Listener as CertificateListenerClient->>Handler: ConfirmPaymentHandler->>Store: find by idempotency keyalt Replay of the same keyStore-->>Handler: existing resultHandler-->>Client: original receiptelse First confirmationHandler->>Handler: enrollment.confirmPaymentHandler->>Store: save ActiveHandler->>Events: publish EnrollmentCompletedNote over Events,Listener: AFTER_COMMIT listener waitsHandler->>TX: commitTX-->>Listener: EnrollmentCompletedListener->>Listener: issue certificateend
The sequence makes two failure modes visible. If the process dies after commit and before the listener finishes, the enrollment is active and the certificate is missing. If the listener ran before commit, a rollback would leave a certificate pointing at an enrollment that is still awaiting payment. The annotation chooses the second bug as the one to eliminate, and leaves the first bug as an operational requirement: certificate issuance must be retryable from stored state, not only from a heap event that is already gone.
Technical Explanation
Where the types live
com.techlearningpro.enrollment.domain
Enrollment, EnrollmentState, Draft, AwaitingPayment, Active, Cancelled
PricingPolicy, Money, EnrollmentQuote
IllegalTransition
com.techlearningpro.enrollment.application
ConfirmPayment, ConfirmPaymentHandler, EnrollmentEvents, EnrollmentStore
com.techlearningpro.enrollment.adapter.web
ConfirmPaymentController
com.techlearningpro.enrollment.adapter.events
SpringEnrollmentEvents, CertificateListener, NoticePipelineListener
com.techlearningpro.enrollment.adapter.notice
NoticePipeline, QuietHoursGate, PreferenceGate, EmailChannel
com.techlearningpro.enrollment.bootstrap
EnrollmentConfiguration
domain imports the JDK. PricingPolicy implementations that need a clock can live in domain if the clock is java.time.Clock, which is a JDK type, or in application if they read configuration. They do not import Spring. adapter.events imports Spring because @TransactionalEventListener is a Spring annotation. That import is legal at the edge and illegal in Enrollment.
An ArchUnit rule from lesson 5, pointed at com.techlearningpro.enrollment.domain and .application, fails the build on org.springframework. The event port is what makes the rule pass while listeners still exist.
Pricing policies as strategies
The policy returns money. It does not save, and it does not publish. Side effects inside a strategy make the selector unusable in a quote preview that must not persist.
javapublic interface PricingPolicy {String code();Money price(EnrollmentQuote quote);}public final class EarlyBirdPricing implements PricingPolicy {private final Clock clock;private final EarlyBirdWindow window;public EarlyBirdPricing(Clock clock, EarlyBirdWindow window) {this.clock = clock;this.window = window;}@Overridepublic String code() {return "EARLY_BIRD";}@Overridepublic Money price(EnrollmentQuote quote) {if (window.includes(clock.instant())) {return quote.listPrice().percentOff(window.percentOff());}return quote.listPrice();}}public final class PricingPolicies {private final Map<String, PricingPolicy> byCode;public PricingPolicies(List<PricingPolicy> policies) {Map<String, PricingPolicy> map = new HashMap<>();for (PricingPolicy policy : policies) {PricingPolicy previous = map.putIfAbsent(policy.code(), policy);if (previous != null) {throw new IllegalStateException("duplicate pricing code " + policy.code());}}this.byCode = Map.copyOf(map);}public PricingPolicy select(String code) {PricingPolicy policy = byCode.get(code);if (policy == null) {throw new UnknownPricingPolicy(code);}return policy;}}
putIfAbsent failing the startup is deliberate. Two beans that both return "EARLY_BIRD" would otherwise make selection depend on list order, and list order of Spring beans is not a business rule unless you have set @Order for a reason you can explain. Unknown codes throw. Returning the list price for an unknown code hides a client bug inside a discount and is how a typo becomes a revenue incident that no alert fires on.
EnrollmentQuote carries the list price and the facts a policy may read: course id, learner segment, the instant the quote was asked for if you passed it in. Prefer arguments over reaching into a static context. A policy that calls SecurityContextHolder is a strategy that only runs behind a servlet and cannot be tested with a plain main or a JUnit method without a thread-local setup.
The state types
Each permitted type implements the same operations. Methods that are illegal throw IllegalTransition. Methods that are legal return the next state. The context replaces its state only when the call returns.
javapublic sealed interface EnrollmentStatepermits Draft, AwaitingPayment, Active, Cancelled {EnrollmentState submit(Enrollment enrollment);EnrollmentState confirmPayment(Enrollment enrollment);EnrollmentState cancel(Enrollment enrollment);}public record Draft() implements EnrollmentState {@Overridepublic EnrollmentState submit(Enrollment enrollment) {return new AwaitingPayment();}@Overridepublic EnrollmentState confirmPayment(Enrollment enrollment) {throw new IllegalTransition("submit before confirming payment");}@Overridepublic EnrollmentState cancel(Enrollment enrollment) {return new Cancelled();}}public record AwaitingPayment() implements EnrollmentState {@Overridepublic EnrollmentState submit(Enrollment enrollment) {throw new IllegalTransition("already submitted");}@Overridepublic EnrollmentState confirmPayment(Enrollment enrollment) {return new Active();}@Overridepublic EnrollmentState cancel(Enrollment enrollment) {return new Cancelled();}}
Enrollment.confirmPayment() does the forwarding:
javapublic void confirmPayment() {this.state = state.confirmPayment(this);}
Passing this lets a state read the enrollment's id and quoted price when a transition needs them. It also lets a careless state call back into confirmPayment and recurse. The states above do not call the context's mutators. They return a value. Keep that discipline. A state that calls enrollment.confirmPayment() re-enters the same method and overflows the stack on the first legal call.
Active and Cancelled follow the same shape. Cancelled.confirmPayment throws. Active.cancel returns Cancelled and leaves refund calculation to the handler, because money movement is a use case with a gateway port, and a state object that calls a gateway has imported a driven adapter into the domain transition. The state records that the enrollment is cancelled. The handler, seeing the transition, invokes the refund policy. If the refund must succeed before the state changes, the handler calls the gateway first and only then cancel, and the crash window between those steps is the same window lesson 5 refused to hide inside a transaction around HTTP.
Commands, handlers, and the event port
javapublic record ConfirmPayment(UUID enrollmentId, String idempotencyKey) {}public interface EnrollmentEvents {void completed(EnrollmentCompleted event);}public record EnrollmentCompleted(UUID enrollmentId, UUID learnerId, Instant at) {}public final class ConfirmPaymentHandler {private final EnrollmentStore store;private final EnrollmentEvents events;private final Clock clock;public ConfirmPaymentHandler(EnrollmentStore store, EnrollmentEvents events, Clock clock) {this.store = store;this.events = events;this.clock = clock;}public ConfirmationReceipt handle(ConfirmPayment command) {Optional<ConfirmationReceipt> prior = store.findReceipt(command.idempotencyKey());if (prior.isPresent()) {return prior.get();}Enrollment enrollment = store.get(command.enrollmentId());enrollment.confirmPayment();ConfirmationReceipt receipt = new ConfirmationReceipt(command.idempotencyKey(), enrollment.id(), enrollment.stateName());store.save(enrollment, receipt);events.completed(new EnrollmentCompleted(enrollment.id(), enrollment.learnerId(), clock.instant()));return receipt;}}
The handler is plain Java. A test constructs it with an in-memory store, a list-collecting EnrollmentEvents, and a fixed Clock. No Spring, no mail server. That test is the one that proves the state transition and the single publication. A second test publishes through SpringEnrollmentEvents only if you care that ApplicationEventPublisher is invoked. Do not boot the container to prove that Draft refuses confirmPayment.
The Spring adapter:
java@Componentpublic class SpringEnrollmentEvents implements EnrollmentEvents {private final ApplicationEventPublisher publisher;public SpringEnrollmentEvents(ApplicationEventPublisher publisher) {this.publisher = publisher;}@Overridepublic void completed(EnrollmentCompleted event) {publisher.publishEvent(event);}}@Componentpublic class CertificateListener {private final CertificateIssuer issuer;public CertificateListener(CertificateIssuer issuer) {this.issuer = issuer;}@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)public void onCompleted(EnrollmentCompleted event) {issuer.issue(event.enrollmentId(), event.learnerId());}}
TransactionPhase lives in org.springframework.transaction.event. The phase constant AFTER_COMMIT is the default; writing it keeps the design visible in review. The listener is a driven adapter. CertificateIssuer is a port if issuance reaches a database or a file store you might fake in a test. For a unit test of the listener, pass a fake issuer and invoke onCompleted directly. The annotation does not have to fire for that test to be valid. An integration test that opens a transaction, publishes, commits, and sees the fake issuer called is the test that locks the phase. Teams that only call the listener method never notice they used @EventListener and are still inside the transaction.
Template method versus a channel strategy
The notice path uses a final skeleton when the invariant is the order of validate, deliver, and log:
javapublic abstract class OutboundNotice {private final NoticeLog log;protected OutboundNotice(NoticeLog log) {this.log = log;}public final DeliveryAttempt send(Notice notice) {requireBody(notice);DeliveryAttempt attempt = deliver(notice);log.record(notice.id(), attempt);return attempt;}protected abstract DeliveryAttempt deliver(Notice notice);private void requireBody(Notice notice) {if (notice.body() == null || notice.body().isBlank()) {throw new BlankNotice(notice.id());}}}
send is final so a subclass cannot log before delivery and skip the invariant. EmailNotice and InAppNotice implement deliver. Quiet hours are a gate in front of every channel, not another hook each subclass must remember. The NoticeLog adapter calls JdbcTemplate.update with bound parameters. It does not subclass JdbcTemplate. Spring documents that class as a tool you call.
Transactions around the handler
The outer Spring service is the composition that lessons 5 and 6 already argued for:
java@Servicepublic class ConfirmPaymentAction {private final ConfirmPaymentHandler handler;public ConfirmPaymentAction(ConfirmPaymentHandler handler) {this.handler = handler;}@Transactionalpublic ConfirmationReceipt confirm(ConfirmPayment command) {return handler.handle(command);}}
Because the listener is AFTER_COMMIT, the certificate work is outside this transaction. The handler's store.save joins it. The handler does not call a mail client. If someone later injects JavaMailSender into the handler, the transaction covers SMTP, the connection pool waits on the mail server, and a mail failure rolls back a payment confirmation that the acquirer already accepted. The event port is what makes that injection a review comment instead of the path of least resistance.
Idempotency sits in EnrollmentStore. The receipt's key has a uniqueness constraint. The handler's read-then-insert is not sufficient under concurrency: two requests can both see an empty prior and both insert. The store catches the unique-constraint failure and returns the winning receipt. Lesson 18 treats that constraint as a repository responsibility. This lesson only requires the handler to call the store rather than check a HashSet on the singleton service, which would be wrong on a second application instance and wrong after a restart.
Practical Implementation or Design Walkthrough
Problem
Learners enroll in a TechLearningPro course. Pricing depends on a policy code. After payment is confirmed, the enrollment becomes active, a certificate is issued, and a notice is sent unless a gate drops it. The team has already had one incident where a copy change in the notice broke pricing, and one incident where a certificate was emitted for an enrollment that rolled back.
Requirements that bind the design
- A quote is priced by exactly one policy code. Unknown codes are rejected.
- The money accepted at submit time is the money confirmation uses. Confirmation does not re-evaluate the early-bird window.
confirmPaymentis legal only from awaiting payment. Other sources throwIllegalTransition.- A repeated confirm with the same idempotency key returns the original receipt and does not publish a second completion event.
- Certificate issuance and notice dispatch observe completion and run after the enrollment commit.
- A notice is dropped when quiet hours apply or the learner has opted out of that channel. Dropped notices are logged. They are not thrown as 500s to the payment client, because the payment client is no longer on the stack when the listener runs.
- Domain and application packages do not import Spring.
Non-functional constraints: confirmation's database work stays on the order of a single row update and a unique insert. Listener work must not extend that transaction. Two confirmations of the same key on different threads result in one active enrollment. Policy beans hold no request state.
Assumptions
- One application process publishes events to itself. A second instance of the service has its own listeners. This lesson does not broadcast across instances. If certificate issuance must happen exactly once in a cluster, the issuer's store has a unique constraint on
enrollment_id, and the listener treats a duplicate as success. The in-process event is a hint, not the lease. - The payment gateway has already authorized funds before
ConfirmPaymentis accepted. This lesson does not call the acquirer. Lesson 22 does. The idempotency key here is the enrollment operation's key. - Clock is injected. Tests pass
Clock.fixed. Production passesClock.systemUTC.
Walkthrough
Submit creates an Enrollment in Draft, selects a PricingPolicy, snapshots Money onto the enrollment, and moves the state to AwaitingPayment. The snapshot is a field, not a function of the current time. Confirmation loads the enrollment, runs the state transition, saves, and publishes. The notice listener builds a Notice from the event and passes it to NoticePipeline.
Gates implement one method and do not call each other:
javapublic interface NoticeGate {void apply(Notice notice, NoticeDecision decision);}public final class NoticePipeline {private final List<NoticeGate> gates;private final NoticeChannel channel;private final NoticeLog log;public NoticePipeline(List<NoticeGate> gates, NoticeChannel channel, NoticeLog log) {this.gates = List.copyOf(gates);this.channel = channel;this.log = log;}public void dispatch(Notice notice) {NoticeDecision decision = new NoticeDecision();for (NoticeGate gate : gates) {gate.apply(notice, decision);if (decision.dropped()) {log.record(notice.id(), decision.reason());return;}}channel.deliver(notice);}}
QuietHoursGate drops when the notice's channel is email and the clock is inside the learner's quiet window. PreferenceGate drops when the learner record says the channel is off. The channel is a Strategy: EmailChannel or InAppChannel. The pipeline does not switch on channel name. Adding a push channel means a new NoticeChannel bean and, if push has a new refusal rule, a new gate. The pipeline class stays.
NoticeDecision is mutable on purpose, confined to one dispatch call, and not stored on the singleton pipeline. Gates write into the decision they are given. A gate that stores the last decision in a field recreates the strategy race from earlier in the lesson.
Error handling
UnknownPricingPolicy and IllegalTransition are domain exceptions. The controller maps them to 422 and 409 respectively. A listener that throws after commit is logged with the enrollment id. The certificate issuer writes a certificate_jobs row in the same transaction as the enrollment if you need a guarantee stronger than the heap event, and the listener becomes an optimization that a job also performs. For the first version, a unique constraint plus a nightly reconciliation that looks for active enrollments without certificates is an honest design. Pretending the listener cannot fail is how the missing-certificate ticket arrives with no query to find the gap.
Tests that lock the design
Draft.confirmPaymentthrows, and the enrollment's state name is unchanged.AwaitingPayment.confirmPaymentyields active.- Two threads call
handlewith one key; the collecting event fake records oneEnrollmentCompleted; the store contains one receipt. PricingPoliciesconstruction throws when two policies share a code.EarlyBirdPricingat a fixed clock inside the window returns the discounted money, and outside the window returns the list price. The policy is constructed withClock.fixed, notInstant.now()inside the test's assertion window.NoticePipelinewith a quiet-hours gate that always drops does not call the channel. With no gate dropping, the channel receives the notice once.- A Spring slice test, optional and slower, commits a test transaction and asserts the certificate fake ran, then rolls back in a paired test by publishing without a commit and asserts the fake did not run. If you only have the first, you have not proved
AFTER_COMMIT.
Expected result
A reviewer can point at the policy code, the state type, the command record, the event port, and the notice pipeline and name a different change for each. A quote preview can price without a database. A confirmation unit test can run without Spring. A new discount is a new PricingPolicy bean and a code the client may send. A new notice refusal is a gate. Neither edit touches Enrollment.confirmPayment.
Enterprise Real-World Scenarios
Notification pipeline for course operations
Launch week sends tens of thousands of enrollment, assignment, and announcement notices in an hour. Quiet hours, opt-out, and a dedupe key of (learnerId, noticeType, day) are gates. The dedupe gate hits the database and runs after the in-memory gates so a quiet-hours drop does not write a row. The pipeline is assembled in one configuration class. Component-scan order is not the sequence.
The channel is a driven adapter over the mail client. A provider timeout is recorded on notice_attempts and retried by a job, not in a loop on the AFTER_COMMIT thread, which would block later listeners. The retry sends only when the provider has not already returned an acceptance id. Otherwise a timeout double-sends. The observers stay in-process. When a second deployable must react, an outbox adapter replaces SpringEnrollmentEvents. ConfirmPaymentHandler stays, which is the port lesson 6 drew for receipts.
Completion state for a multi-day cohort
A cohort is not finished when payment clears. Learners move through Registered, InProgress, Completed, and Expired. Each state type implements markLessonComplete, expire, and extend. An extension is a Strategy keyed by reason code: paid, accessibility, or instructor grant. Expired returns to InProgress only when that strategy approves. Flags on one Enrollment class would put finance's recognition rule and support's extension rule in the same type.
The certificate listener subscribes to CohortCompleted. Finance does not. A recognition row that must commit with registration is a second write in the registration handler, inside the same transaction. Observer is the seam when the user's operation may succeed and the reaction may be retried. When the reaction's failure must undo the registration, the write stays in the command. A shared "event listener" that swallows errors will show a registered learner and a missing recognition row, and the transaction will already have committed.
Best Practices
- Name the change that justifies the pattern in the class's package note or in the decision record. "Strategy because a second pricing code shipped in March" ages well. "Strategy because patterns" does not.
- Keep Strategy and State objects free of request fields. Pass the quote, the clock instant, and the enrollment into the method.
- Make template skeletons
final. A non-final skeleton will be overridden during an incident, and the audit hook will be the line that was skipped. - Publish domain facts through a port. Implement the port with
ApplicationEventPublisherat the adapter. Listen with@TransactionalEventListenerwhen the fact must not be observed on a rollback. - Treat after-commit listeners as at-most-once relative to the process. Durable work gets a row written in the original transaction, or a unique constraint that makes the listener's retry safe.
- Select strategies with an explicit code and fail on duplicates and on unknown codes.
- Snapshot decisions that the user has seen. Re-running a time-based strategy later is a behavior change dressed as a refactor.
- Map domain exceptions to HTTP at the controller. Leave
IllegalTransitionwithout a status code field. - Prefer a
Listof gates constructed in one configuration class over a hand-linkednextpointer. Use@Orderonly when the scan is the construction mechanism, and assert the order in a test that reads the list. - When a reaction must be atomic with the command, keep it in the handler. Do not publish an event to simulate a local method call.
- Tie the pattern back to lesson 4 in review: one reason to change per type, substitution that honors the contract, dependency on the interface you own.
Common Mistakes and Anti-Patterns
A strategy interface with a switch in the context. The context still inspects a type code and then calls the strategy, or worse, ignores the strategy for one code. The new policy looks installed and never runs for the case the author tested by calling the policy directly. The selector should be the only branch, and it should branch on the code to find the object, not on the code to perform the discount.
Request state in a singleton strategy or gate. EarlyBirdPricing stores this.quote = quote so a later method can see it. Two concurrent quotes interleave. The defect is intermittent and the unit test is single-threaded. Fields on singleton collaborators are for dependencies and configuration. Method arguments carry the request.
Observers that are required for correctness, with no durable record. The certificate exists only because a listener ran in memory after commit. A deploy restarts the process between commit and the listener. The enrollment is active, the certificate is absent, and there is no row a job can find except by scanning every enrollment. Write the need, or accept the reconciliation scan as a stated operation.
@TransactionalEventListener on a method that publishes from a non-transactional caller. The listener never runs. The publisher logs success. The fix is either a transaction around the publish or fallbackExecution = true with eyes open: fallback runs the listener even when there is no transaction, which reintroduces the "observed a fact that never committed" case if a transaction later appears and rolls back. Choose it only for publishers that are genuinely non-transactional.
A command bus as the first abstraction. Reflection, string names, and a thread pool arrive before there are three commands. Failures become "the bus lost it." A record and a handler the controller calls are enough until a second driving adapter needs the same dispatch table.
Deep template hierarchies. OutboundNotice grows MaybeLocalizedNotice, then MaybeLocalizedRetryingNotice, each overriding two hooks and calling super in a different order. The skeleton is no longer the invariant; the subclass is. Collapse back to one skeleton and strategies for channel, locale, and retry.
String status beside a state object. Enrollment keeps both String status and EnrollmentState, and one of them is updated. Callers read the string. The sealed type is decorative. One representation, and the string the API returns is derived from the state (state.name()), not stored as a second source of truth.
Linked handlers that swallow the request. A chain element catches every exception and returns, so later gates do not run and the caller sees success. Gates in this lesson either drop with a reason or throw. They do not catch broadly. The pipeline logs the drop reason. A swallowed exception is an unlogged drop.
Troubleshooting and Design Review
Symptom: certificates appear for enrollments that are still awaiting payment. The listener is @EventListener or a direct call inside the handler, and it runs before commit. A later constraint failure rolls the enrollment back. The certificate write used another transaction or an external system. Move the reaction to AFTER_COMMIT, or write the certificate row in the same transaction if it must match exactly. Confirm with a test that forces the store to throw after the publish and asserts the issuer was not called when the phase is after commit. If the issuer was called, the phase is not what you think.
Symptom: certificates never appear, and the listener has no log line. Check that the publisher's thread had an active transaction. Check that the event type on publishEvent is exactly the listener parameter type; a subclass or a wrapper record will not match unless the listener parameter is the type you publish. Check that the listener bean is created: a missing @Component or a profile that excludes the adapter package is a common cause. ApplicationEventPublisher does not fail when nobody listens.
Symptom: two completion events for one idempotency key. The handler checks a field on the @Service, or checks and inserts without a uniqueness constraint. Under two threads both checks pass. Add the unique index and map the constraint violation to "return the existing receipt, do not publish again." The publish has to sit after the successful insert, not before the catch block in a way that both threads publish. Structure the store method so only the winner returns a flag that says "you inserted."
Symptom: the wrong price, and both policies look correct in isolation. Duplicate codes and last-wins map construction, or the quote still calls Instant.now() inside the policy while the test clock is unused. Fail startup on duplicate codes. Pass the clock in. Log the policy code on the quote row so support can see which strategy ran without a debugger.
Symptom: quiet hours pass in staging and fail in one region. The gate reads the server's default zone. Learners have a zone on the profile. The notice carries the instant and the zone. The gate converts. A test with a fixed clock at 22:00 in America/Los_Angeles and a notice in UTC locks the conversion. This is a design defect in the gate's inputs, not a mail-provider defect.
Design review questions. Can you name the change that adds a class, and the change that must not touch that class? Does any singleton store per-request data? Does any listener perform work that must roll back with the command? Does the state type allow a transition the product forbids, and is that transition a test? Does the template's skeleton stay final? Does the domain package import Spring only on the event adapter? If the process dies after commit, what job repairs the listener's side effect?
Interview Questions and Answers
Q1. When would you use Strategy instead of a conditional, and when would you leave the conditional?
Use Strategy when a second algorithm is a real change with its own reason to exist, and callers should depend on one interface. Pricing codes that finance adds without editing confirmation are the usual case. Leave the conditional when there is one algorithm, the branches are small, and extracting a type would force a selector, a registry, and a code field nobody else uses. Lesson 4's open-closed principle is the justification, and it requires a variation that is actually open. A single if that checks a feature flag you will delete next week can stay an if.
Q2. How is Spring's application event mechanism related to Observer, and what does AFTER_COMMIT guarantee?
ApplicationEventPublisher.publishEvent notifies listeners registered in the same application context. That is the Observer collaboration: the publisher does not declare the listeners as constructor dependencies. @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) registers a listener that runs after the publishing transaction commits. It guarantees that the listener does not observe a transaction that later rolls back. It does not guarantee delivery if the process stops after commit, it does not retry, and it does not run at all when no transaction is active unless fallback execution is enabled.
Q3. A certificate listener runs after commit and then throws. What is the learner's state, and what do you add?
The enrollment is active. The certificate is missing. The transaction cannot roll back. Add a durable record written in the original transaction, such as a job row, or make issuance idempotent under a unique enrollment_id and run a reconciliation query for active enrollments without certificates. Logging the exception is necessary and insufficient.
Q4. Why is a command record not the same thing as building a command bus?
The record gives the request a type, a validation boundary, and something you can store for idempotency and audit. A bus adds dispatch, threading, and failure policy that Spring Framework does not provide as a command abstraction. With one or two driving adapters, the adapter can call the handler. Add a dispatch table when several command types share one entry point. Delay a reflective bus until the operational questions have answers.
Q5. Is JdbcTemplate a Gang of Four Template Method?
It is a template in Spring's naming, implemented with callbacks. You pass a ResultSetExtractor or a RowMapper; you do not subclass JdbcTemplate to fill in a hook. The textbook Template Method uses inheritance and a usually final skeleton. AbstractApplicationContext.refresh is closer to that textbook shape. TransactionTemplate.execute is the callback shape applied to a PlatformTransactionManager. In enrollment code, OutboundNotice.send being final with an abstract deliver is the inheritance form. Use it for an ordering invariant, and prefer a Strategy collaborator when the variant is selected per request.
Q6. How do you test a State implementation without starting Spring?
Construct the state record, call the operation, and assert either the returned state's class or the domain exception. Then construct Enrollment with that state and assert the context replaced its state only on success. Fixed clocks and in-memory stores cover the handler. Spring is involved only in a separate test that the listener bean reacts after commit. Illegal transitions should be tests with the same status as the happy path, because they are the defect the pattern exists to make obvious.
Q7. What breaks if a @Component Strategy stores the current quote in a field?
The bean is a singleton shared by request threads. One thread's quote replaces the other's, and the price is computed from the wrong input. The failure is timing-dependent. The fix is to pass the quote into price and keep fields for the clock and the window configuration only. The same rule applies to gates and to state objects if you mistakenly make one mutable state bean serve all enrollments. State objects in this lesson are immutable records returned as the next state, so they do not carry a shared cursor.
Q8. How does a list of gates differ from a classic chain of handlers?
Both apply handlers in a sequence and both can stop early. The classic drawing stores the successor inside each handler. A list stores the sequence in the pipeline, which you can assert in one test and assemble in one configuration class. @Order is a Spring mechanism for building that list. It is not a reason to hide the sequence. Handlers that call next themselves can form cycles and can skip successors after catching exceptions. Gates that only write a decision are easier to test one at a time.
Q9. A new pricing rule ships every week. What changes, and what stays closed?
Add a PricingPolicy implementation and a new code. Register it as a bean so PricingPolicies receives it in the list. Enrollment.confirmPayment, the controller's mapping, and the other policies stay closed, which is the open-closed outcome lesson 4 describes. What does not stay closed is the client's allowed code set and the startup check for duplicate codes. If the rule is "fifteen percent off on Tuesdays for one course," it is still a policy with a code, not a branch inside EarlyBirdPricing, unless product has decided those two calendars are one policy with one owner.
Q10. Where do these patterns sit relative to hexagonal architecture?
The state, the pricing interface, and the command record sit in the domain or application core. The handler is the use case. EnrollmentEvents is an outbound port. SpringEnrollmentEvents, the listeners, EmailChannel, and the controller are adapters. Lesson 6's rule is unchanged: core types do not import Spring MVC, Spring JDBC, or the mail client. Observer does not mean the entity calls ApplicationEventPublisher. It means the use case calls a port, and the adapter adapts.
Q11. When is an in-process Observer the wrong seam?
When the reaction must commit or fail with the original write. Finance recognition that is mandatory belongs in the handler's transaction, as a second repository call. An observer is appropriate when the user's operation can succeed even if the reaction needs a retry: certificates, notices, analytics. Also avoid Observer when you need a strong order across processes. publishEvent does not leave the JVM.
Q12. How do you tell State from Strategy in a design review?
Ask what selects the object and whether it changes during the aggregate's life. A caller-selected, sibling-unaware algorithm is Strategy. A mode that transitions, and that disables operations, is State. If the object never transitions, rename it. If both a status string and a delegate exist, delete one. The sealed interface is doing its job when adding a status forces you to implement every operation and the compiler rejects a partial edit.
Hands-On Exercises
Exercise 1: Pricing strategies and the enrollment state
Problem. Implement PricingPolicy, PricingPolicies, and the sealed EnrollmentState for draft, awaiting payment, active, and cancelled. Snapshot money at submit. Confirm payment only from awaiting payment.
Tasks.
- State the rule that confirmation does not re-price, and write the test that would fail if
confirmPaymentcalled the policy again with a clock moved past the early-bird window. - Fail construction of
PricingPolicieson a duplicate code, and failselecton an unknown code. - Draw the state diagram and implement the illegal transitions as
IllegalTransition. - Keep Spring out of the domain types. The test constructs the handler with fakes.
- Document the idempotency behavior under two threads, including where the uniqueness constraint lives.
Deliverables. Tests, the state diagram, the policy interface, and a short decision note on why refunds are not inside the state object.
Rubric. Requirements and the no-reprice rule 15%. State and strategy design 25%. Tests that fail for the right reason 20%. Concurrency and idempotency 20%. Trade-offs 10%. Clarity 10%.
Exercise 2: Notification pipeline
Problem. Implement the notice pipeline for the completion listener using gates and a channel Strategy.
Tasks.
- Specify quiet hours, opt-out, and dedupe. Decide the gate order and justify it.
- Model drops as reasons on
NoticeDecision, not as exceptions that become HTTP 500 for the payment caller. - Show that a singleton gate stores no per-notice fields.
- Describe the crash after commit: what row exists, and how a retry avoids a second email.
- Compare a linked chain with a
Listpipeline in one paragraph of the decision note. - Identify the trust boundary: learner preferences are data, not a code branch in
EmailChannel.
Deliverables. Pipeline types, one test per gate, one test that the channel is skipped on a drop, and the failure note for provider timeouts.
Rubric. Same weights as exercise 1. A solution that retries a timed-out send with no idempotency story does not pass the reliability criterion.
Lesson Summary
Strategy, Observer, Command, Template Method, and State each absorb one kind of behavioral change on the enrollment path. Policies are stateless singletons selected by code. Completion is a command that moves a sealed state and publishes through a port. Listeners run after commit and are allowed to have their own recovery because they are outside the transaction. Notice sending keeps a fixed order of checks and a replaceable channel, with gates in a list rather than a hidden chain. Spring supplies the event publisher, transactional listener phase, and callback-style JDBC and transaction templates. It does not supply your pricing interface or your enrollment states, and those types stay in the core so lessons 5 and 6 still hold.
The patterns are unjustified when the variation is imaginary, when a listener hides a write that must be atomic, or when a template hierarchy is doing a strategy's job. Iterator, Mediator, and Visitor stay out of this service until a real traversal, a real mesh, or a stable hierarchy with many operations shows up. The next lesson takes the store this handler already depends on and treats it as a repository with a transaction boundary and, where the predicates are dynamic, a specification.
Further Reading
- Gamma, Helm, Johnson, Vlissides. Design Patterns. The Strategy, Observer, Command, Template Method, State, and Chain of Responsibility chapters are the catalog definitions this lesson specializes for enrollment.
- Spring Framework reference, Application Events:
ApplicationEventPublisher,@EventListener,@TransactionalEventListener, andTransactionPhase. - Spring Framework reference, Transaction Management:
TransactionTemplateand@Transactionalpropagation, including the warning about long-running work inside a transaction. - Spring Framework reference, Data Access with JDBC:
JdbcTemplatecallbacks and exception translation. - SOLID Principles in Practice, Clean Architecture, and Hexagonal Architecture and Ports & Adapters for the dependency direction the patterns have to respect.
- Martin Fowler, Patterns of Enterprise Application Architecture, Identity Map and Unit of Work, as the bridge into lesson 18's repository and transaction boundary.