UML Sequence and Activity Diagrams
A class diagram says which objects are allowed to exist. A sequence diagram says which of them speak, in what order, and which reply means the order was placed. An activity diagram
UML Sequence and Activity Diagrams
Advanced System Design & Software Architecture · Lesson 13 · Low-Level Design and Design Patterns
Lesson Overview
A class diagram says which objects are allowed to exist. A sequence diagram says which of them speak, in what order, and which reply means the order was placed. An activity diagram says how a decision and a fork move through that work, often across teams. TechLearningPro's checkout had a class model that was already reviewed and a runtime that was not. The sequence in the design doc showed PlaceOrder saving a row and then returning an id. The implementation sent the receipt email inside the same method, before the transaction committed, and the diagram had no combined fragment for "the database rolls back." Learners received a receipt for an order the database did not keep. Support treated the email as proof. Finance treated the missing row as proof. Both were right about their artifact and wrong about the system, because the picture had hidden the commit.
This lesson draws sequence and activity diagrams for a Spring Boot 3 checkout and holds them to the call stack you can see in a debugger. Lifelines are objects, not layers with vague names. Messages are calls or returns. A synchronous call waits. An asynchronous message does not. Combined fragments (alt, opt, loop) show the branches you intend to test. Activity partitions (swimlanes) show which tier owns a decision. The Spring mapping is concrete: a filter chain, DispatcherServlet, a handler mapping, a controller, a transactional proxy, PlatformTransactionManager, the store, and a listener that runs after commit. /learn/low-level-design/sequence-diagrams and /learn/low-level-design/activity-diagrams introduce the symbols. Here the pass mark is a diagram a teammate can implement without inventing a commit point, and a diagram you can reject because the arrows skip the proxy.
Learning Objectives
By the end of this lesson you will be able to:
- Draw a sequence with lifelines, synchronous calls, return messages, and activation that matches a Spring MVC request.
- Use
alt,opt, andloopfragments for payment decline, optional receipt, and per-line checks, and name the test that covers each fragment. - Distinguish a synchronous call from an asynchronous message, and place the receipt on the side of that distinction that matches commit.
- Explain where
@Transactionalappears on the sequence (the proxy andPlatformTransactionManager), including the self-invocation path that never starts a transaction. - Draw an activity with partitions for learner, web, domain, payment, and database, including a decision for timeout and a final node for both success and failure.
- State what
TransactionalEventListenerwithAFTER_COMMITdoes when no transaction is active. - Review a sequence that returns HTTP 201 before commit, or that puts a business rule on the database lifeline.
- Choose sequence versus activity for a given review question, and refuse to draw both when one already answers it.
Prerequisites
- Lesson 12's rule that a diagram is a claim about structure. This lesson's claim is about time.
- Lesson 4's placement flow at the level of "save, then receipt, and a receipt failure is not an order failure." You do not need the discount class hierarchy.
- Java 21 and the shape of a Spring
@RestControllermethod. You should know that Spring beans can be proxied. - The words commit and rollback. You do not need a database administration background. You do need to say on which message the commit happens.
Detailed Core Concepts
Lifelines are objects
A sequence diagram is an interaction. Time runs downward. Each lifeline is a participant: a named instance. checkoutController, placeOrder, orders (the store), payments, and tx (the transaction manager) are lifelines. "Database" is acceptable as a lifeline when the message is SQL or commit. "Backend" is not a lifeline. It hides who checks the price.
The box at the top is the instance. The dashed vertical line is its life. A filled bar on that line is an execution specification (activation): the participant is on the stack. In a synchronous call, the caller's activation stays open until the return. That is why a long bar on DispatcherServlet covering the whole request is honest, and a diagram with no returns is not: you cannot see who is still waiting.
Creation of a short-lived object can be a message that points at the lifeline's head (create). Destruction is a stop on the lifeline. Checkout rarely needs either. The controller and the use case are Spring singletons for the process; the Order is created inside place and can be a local, not a lifeline, unless the interview is about the entity's methods. Add a lifeline when a message to it carries a design risk. Extra lifelines are how sequences become posters.
Synchronous calls and asynchronous messages
A synchronous call is a filled arrow. The caller waits for a return message, drawn dashed. controller calls placeOrder.place and does not write the HTTP response until place returns an OrderId or throws. That waiting is the activation.
An asynchronous message has an open arrowhead. The sender continues. UML uses it for signals and for fire-and-forget calls. Mermaid draws it with -). The receipt after commit is asynchronous relative to the HTTP request only if the request completes without waiting for SMTP. If place calls the mail client and blocks, the arrow is synchronous, the request thread is occupied, and a slow provider stalls Tomcat's pool. Draw the arrow you implemented. Teams get into trouble by drawing an open arrowhead for a call that is a blocking JavaMailSender.send.
A found message is an arrow from the diagram edge, the incoming HTTP request. A lost message would leave the edge with no receiver. Checkout should not have lost messages. If the receipt is "sent" and no lifeline receives it, the diagram has wished the email away.
Combined fragments
A combined fragment is a box with an operator and one or more operands.
| Operator | Meaning in checkout | What you owe |
|---|---|---|
alt | Payment authorized versus declined | Two tests, two HTTP outcomes |
opt | Send a receipt only when the learner has an email on file | A test that absence of email still commits |
loop | Validate each line | Empty cart rejected before the loop; one bad line aborts |
break | Exit the interaction on fraud hold | The rest of the sequence does not run |
par | Two regions that truly overlap | A statement of what happens if one fails |
ref | "See the pricing interaction" | The referenced diagram exists |
par is the fragment people draw for courage. Pricing and payment in parallel look fast and race on the total. Do not draw par unless the implementation uses concurrency and you can say which result wins. A single request thread is alt and opt, not par.
Guards go in brackets on an operand: [authorized], [declined]. A guard that says [success] is too vague to test. Name the condition the code branches on.
Activity diagrams
An activity is a workflow of actions. It is better than a sequence when the question is "what are the decisions and who owns them?" and worse when the question is "which method calls which." Use both only when both questions are open. Checkout in this lesson uses a sequence for the Spring call stack and an activity for the business decisions a product manager can read.
Nodes you will actually draw:
- Initial node — one solid dot. The learner submits.
- Action — a rounded box, "authorize payment," "insert order."
- Decision — a diamond with guards on the outgoing edges. One decision, mutually exclusive guards, and an else edge so a new status code cannot fall on the floor.
- Merge — a diamond that joins alternative paths back together. Do not confuse it with a join.
- Fork and join — thick bars. A fork starts concurrent flows; a join waits for all of them. Same caution as
par. - Activity final — a bullseye. The checkout attempt is over, whether placed or refused. A flow final (circle with an X) ends one path, not the whole activity. Use it when one branch of a fork should die without killing the others. This checkout has no fork, so a bullseye per outcome is enough if you prefer two finals. UML allows multiple activity finals.
- Partition (swimlane) — a column for a responsibility. Learner, web adapter, domain, payment collaborator, database. An action sits in the lane of the type that performs it. "Charge the card" in the database lane is a category error unless a stored procedure really does it. In this design the payment lane calls a port and the database lane only commits rows you own.
Object flow can show an Order token moving from domain to database. Skip it when the sequence already names the argument. A crowded activity that repeats every parameter is a sequence drawn with the wrong stencil.
What the diagrams are not
They are not a substitute for the transaction isolation level, and they are not a capacity plan. They make the order of effects visible so those other reviews have something to point at. Lesson 37 covers idempotent retries. This sequence assumes the client sends an OrderId and the store rejects a duplicate. The alt for duplicate key is part of the picture, in one operand, not a new framework.
Architecture and Design Diagrams
The request path uses real Spring types as lifelines. The transactional proxy is the object the controller holds. The target PlaceOrder sits behind it. Omitting the proxy is how a diagram claims a commit that self-invocation will not perform.
mermaidsequenceDiagramactor Learnerparticipant Servlet as DispatcherServletparticipant Controller as CheckoutControllerparticipant Proxy as PlaceOrder proxyparticipant Tx as PlatformTransactionManagerparticipant Place as PlaceOrderparticipant Pay as PaymentAuthorizerparticipant Store as OrderStoreLearner->>Servlet: POST /api/ordersServlet->>Controller: place(body, principal)Controller->>Proxy: place(orderId, lines, memberId)Proxy->>Tx: getTransactionTx-->>Proxy: statusProxy->>Place: place(...)loop each linePlace->>Place: reject empty quantityendPlace->>Pay: authorize(amount, key)alt declined or timed outPay-->>Place: declinePlace-->>Proxy: PaymentDeclinedProxy->>Tx: rollbackProxy-->>Controller: PaymentDeclinedController-->>Learner: 402else authorizedPay-->>Place: authorization referencePlace->>Store: save(order)Store-->>Place: okPlace-->>Proxy: orderIdProxy->>Tx: commitProxy-->>Controller: orderIdController-->>Learner: 201 orderIdend
The return to the learner is after commit on the authorized path and after rollback on the declined path. The HTTP status is chosen in the controller, which is the web lane, not inside PlaceOrder. A duplicate primary key from save is a third operand you should add if the client retries: the store throws, the proxy rolls back if the insert was the only write (there is nothing to undo if the database already rejected the statement), and the controller maps the conflict to 409. Say that operand exists even if the first drawing focuses on payment.
The receipt is not on the request's critical path. PlaceOrder publishes a domain event while the transaction is still open. A listener runs after commit and does not share the request thread's transaction.
mermaidsequenceDiagramparticipant Place as PlaceOrderparticipant Bus as ApplicationEventPublisherparticipant Listener as ReceiptListenerparticipant Mail as ReceiptSenderPlace->>Bus: publish OrderPlacedNote over Place,Bus: still inside the transactionNote over Listener: AFTER_COMMITBus--)Listener: OrderPlacedopt email presentListener->>Mail: send(orderId, total)Mail-->>Listener: ok or logged failureend
The open arrowhead into the listener matches @TransactionalEventListener: the publisher returns to PlaceOrder, the transaction commits, then the listener runs. A failure in send is logged. It does not change the 201, because the 201 was already decided. If you draw a synchronous arrow from Place to Mail before commit, you have specified the bug from the overview.
The activity is the same checkout for a reader who will not learn Spring's class names. Partitions are subgraphs. Diamonds are decisions. Guards are explicit.
mermaidflowchart TDsubgraph learnerLane [Learner]submit([Submit cart])endsubgraph webLane [Web adapter]authn[Read member from principal]created[Respond 201]refused[Respond 402 or 409]endsubgraph domainLane [Domain]lines{Each line valid?}price[Price and authorize request]save[Ask store to save]endsubgraph payLane [Payment]decision{Authorization?}endsubgraph dbLane [Database]commit[Commit order row]rollback[Rollback]endsubmit --> authn --> lineslines -->|no| refusedlines -->|yes| price --> decisiondecision -->|declined or timeout| rollback --> refuseddecision -->|authorized| save --> commit --> created
Timeout sits in the same decision as decline because both must avoid commit. They can differ in the HTTP body ("try again" versus "card refused"). The activity's job is to show that neither path inserts an order. Splitting hairs belongs on the sequence as two alt operands if operations treats them differently (retryable versus not).
Technical Explanation
Where Spring actually spends the arrows
DispatcherServlet is the front controller registered by Spring Boot for the servlet path. Its dispatch looks up a HandlerMapping. For @PostMapping, that is RequestMappingHandlerMapping, which matches method, path, and content type. RequestMappingHandlerAdapter invokes the controller method and, on the way out, asks an HttpMessageConverter to write the body. MappingJackson2HttpMessageConverter is the usual JSON converter on Boot 3 when Jackson is on the classpath. Those types belong on a sequence when the bug is "the method never ran" or "the body was empty." They do not belong on every interview whiteboard. The abbreviated sequence above starts at the servlet and the controller, which is the right altitude for a transaction argument. Drop to the adapter and the converter when the defect is in binding, not in money.
Filters run before the servlet. If Spring Security is on the classpath, FilterChainProxy authenticates and sets the Principal before CheckoutController reads it. The sequence's first domain-relevant fact is that memberId comes from that principal. Drawing the client posting memberId in JSON as the identity is an authorization bug from lesson 11, now visible as a message argument.
The transactional proxy
@EnableTransactionManagement is on in a Spring Boot application that has a transaction manager, which spring-boot-starter-jdbc brings in through Boot's TransactionAutoConfiguration. That infrastructure wraps beans carrying @Transactional. The bean must be created by the container. A controller that does new PlaceOrder(...) holds the raw instance, and the sequence's proxy lifeline is a fiction. The default advice rolls back on RuntimeException and Error, and commits when the method returns normally, including when a checked exception is thrown. A PaymentDeclined that extends Exception and not RuntimeException will commit unless you set rollbackFor. Make declined a runtime exception, or set rollbackFor, and make the sequence's rollback arrow match. This is a common reason the diagram says rollback and the row remains.
The object the controller calls must be the proxy. Spring injects the proxy when the collaborator is another bean. A call from place to another method on the same instance (this.save()) does not pass through the proxy. The inner @Transactional is invisible. The sequence that shows Controller -> PlaceOrder.place -> PlaceOrder.save with save annotated transactional is the bug, drawn accurately. The fix is one transactional method that contains the writes, called from the outside, or a second bean. AspectJ mode weaving is a different mechanism (mode = AdviceMode.ASPECTJ) and is not the Boot default. Do not assume it.
PlatformTransactionManager in a JDBC application is DataSourceTransactionManager. getTransaction binds a connection to the thread. commit or rollback unbinds it. The sequence should show one get and one completion on the authorized path. A second REQUIRES_NEW transaction is a second pair of arrows and a second connection. Draw it only if a nested audit write must survive the order's rollback. Nested transactions by accident are how locks pile up.
After-commit listeners
ApplicationEventPublisher.publishEvent inside a transactional method, with a listener annotated @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT), runs the listener after a successful commit. The listener does not run after rollback, which is what you want for a receipt. The default fallbackExecution is false: if the publisher is not in a transaction, the listener does not run at all. A unit test that calls place without a transaction manager will commit nothing and send nothing, and a developer may "fix" it by sending mail inside place. Set up the test with a transaction, or set fallbackExecution only if you truly want the listener when there is no transaction. Say which on the diagram as a note.
AFTER_COMMIT listeners still run on the calling thread by default, after the commit and before the controller returns, unless you put the listener on an executor (@Async, with @EnableAsync, and a task executor). That choice changes the arrow. Blocking SMTP after commit still holds the request thread and no longer holds the database connection. That is better than mail inside the transaction and worse than a queue. For this lesson the listener may block after commit; the 201 waits for the attempt; a failure is logged and still returns 201. If product requires the 201 to return in tens of milliseconds regardless of SMTP, the arrow becomes asynchronous onto a queue, and the listener moves to a consumer. Draw that as a different sequence. Do not label the blocking call async.
Idempotent duplicate and the alt operand
The client supplies OrderId. OrderStore.save inserts. A second request with the same id hits a primary-key violation. The sequence's alt operand is "duplicate." The transaction rolls back (the statement already failed), and the controller returns 409. It does not authorize the card again if the first request might have authorized. That last sentence is the start of idempotency, owned by lesson 37. This diagram's duty is to show a second authorize as a separate message you have not justified. If authorize sits before save, a retry can authorize twice unless the payment key is idempotent. Put the idempotency key on the authorize message label. The key equals the order id in this slice. The payment collaborator must send it. The diagram fails review if the message is authorize(amount) with no key, because a retry is then a second charge.
Timeouts as messages that return
A timeout is not the absence of a lifeline. It is a return, or a failure message, after a bound. PaymentAuthorizer.authorize returns a decline of kind TIMED_OUT or throws a dedicated exception the alt guard names. An activity edge labeled "timeout" that rejoins the success path into commit is the incident waiting to happen. The edge goes to rollback and a response that tells the client the outcome is unknown, so they retry with the same order id and the same payment key. "Unknown" is a different guard from "declined." Three operands are worth it: authorized, declined, unknown. Unknown must not show a receipt listener. You do not know whether the provider captured funds. Lesson 37's idempotent retry is the follow-up, not a second charge with a new key.
Practical Implementation or Design Walkthrough
Problem
Specify and implement the checkout interaction so the sequence, the activity, and the Spring code agree: no order row without authorization, no receipt without a commit, no second charge on a retry with the same order id, and a controller that maps outcomes to HTTP without containing price math.
The use case the proxy calls
javapackage com.techlearningpro.checkout;import java.util.Objects;import org.springframework.context.ApplicationEventPublisher;import org.springframework.transaction.annotation.Transactional;public class PlaceOrder {private final OrderStore orders;private final PaymentAuthorizer payments;private final ApplicationEventPublisher events;public PlaceOrder(OrderStore orders, PaymentAuthorizer payments,ApplicationEventPublisher events) {this.orders = Objects.requireNonNull(orders);this.payments = Objects.requireNonNull(payments);this.events = Objects.requireNonNull(events);}@Transactionalpublic OrderId place(OrderId id, MemberId memberId, java.util.List<OrderLine> lines) {if (lines == null || lines.isEmpty()) {throw new IllegalArgumentException("lines");}for (OrderLine line : lines) {if (line.quantity() < 1) {throw new IllegalArgumentException("quantity");}}Money total = price(lines);Authorization auth = payments.authorize(new PaymentKey(id.value()), total);if (!auth.approved()) {throw new PaymentDeclined(auth.reason());}orders.save(Order.place(id, memberId, lines, total, auth.reference()));events.publishEvent(new OrderPlaced(id, total, memberId));return id;}private Money price(java.util.List<OrderLine> lines) {Money sum = new Money(java.math.BigDecimal.ZERO, lines.getFirst().unitPrice().currency());for (OrderLine line : lines) {sum = sum.plus(line.lineTotal());}return sum;}}
PaymentDeclined extends RuntimeException, so the default rollback rule matches the sequence. price is a private method on the same instance. That is legal: it is not a transactional boundary, it is an internal call, and the diagram shows it as a self-call inside the activation of place, not as a second transaction. The loop in the sequence is the quantity check. Pricing is a second loop you may collapse into one lifeline message, price(lines), to keep the diagram readable. Collapsing is allowed when the omitted calls do not change commit behavior. The quantity check can fail before authorize. The sequence should show that failure above the payment message so nobody thinks a bad line still hits the provider.
Order, Money, and OrderLine are the types from lesson 4's shape: a line total, a currency check, a non-empty place. They are not repeated in full here. The interaction is the point.
Controller: HTTP stays in the web lane
javapackage com.techlearningpro.checkout.web;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RestController;@RestControllerclass CheckoutController {private final PlaceOrder placeOrder;CheckoutController(PlaceOrder placeOrder) {this.placeOrder = placeOrder;}@PostMapping("/api/orders")ResponseEntity<Placed> place(@RequestBody PlaceRequest body, java.security.Principal principal) {try {OrderId id = placeOrder.place(new OrderId(body.orderId()),new MemberId(principal.getName()),body.lines());return ResponseEntity.status(HttpStatus.CREATED).body(new Placed(id.value()));} catch (PaymentDeclined ex) {return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED).build();}}}
402 Payment Required is a real status in HttpStatus. Use it only if the client contract says so; a 422 with a problem body is equally mappable. The diagram's status codes and the ResponseEntity must match. principal.getName() is the member id source. The request body in this sketch carries orderId and lines, not the member. A body field memberId would be a second, untrusted source and should not appear as a message argument.
HttpStatus.PAYMENT_REQUIRED exists on Spring's HttpStatus enum. Duplicate-key handling belongs in a @ExceptionHandler that returns 409, not in a catch-all that returns 201. The handler is another alt return into the learner lifeline.
The listener the second sequence names
javapackage com.techlearningpro.checkout;import org.springframework.transaction.event.TransactionPhase;import org.springframework.transaction.event.TransactionalEventListener;public class ReceiptListener {private final ReceiptSender mail;public ReceiptListener(ReceiptSender mail) {this.mail = mail;}@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)public void onPlaced(OrderPlaced event) {try {mail.send(event.orderId(), event.total());} catch (RuntimeException ex) {// The order row is committed. The receipt is replayable from the order id.}}}
The empty catch is a stand-in for a log with the order id, using SLF4J's LoggerFactory.getLogger(ReceiptListener.class), which Boot already wires through Logback. Swallowing without a log is how the opt fragment becomes invisible in production. The listener does not throw, because an exception here is too late to change the commit and, depending on the executor, can be easy to lose. Record the failure in a table the support tool reads if email is a product promise. That table insert is a new message on the sequence, after commit, in its own short transaction. Add it when support cannot work from logs. Until then, say the operational record is the log line.
A test that the decline does not save
javaimport static org.junit.jupiter.api.Assertions.assertThrows;import static org.junit.jupiter.api.Assertions.assertTrue;import java.math.BigDecimal;import java.util.List;import org.junit.jupiter.api.Test;class PlaceOrderSequenceTest {@Testvoid declineDoesNotSaveAndDoesNotPublish() {FakeOrders orders = new FakeOrders();FakeEvents events = new FakeEvents();PlaceOrder place = new PlaceOrder(orders, (key, amount) -> Authorization.declined("no"), events);OrderLine line = new OrderLine(new Sku("COURSE-1"), 1, new Money(new BigDecimal("40.00"), "USD"));assertThrows(PaymentDeclined.class,() -> place.place(new OrderId("o-1"), new MemberId("m-1"), List.of(line)));assertTrue(orders.saved.isEmpty());assertTrue(events.published.isEmpty());}}
The fakes are test doubles with public lists. This test does not boot DispatcherServlet. It locks the domain operands: decline throws, save is skipped, the event is not published, so an after-commit listener has nothing to hear. A @SpringBootTest that posts JSON belongs on the controller operand and checks 402 versus 201. One test class that does both becomes slow and hides which fragment failed. Split them the way the lifelines are split.
The fakes' types (FakeOrders, Authorization.declined) are local test code you write to record calls. They are not a framework. The assertion is the fragment.
Enterprise Real-World Scenarios
The receipt that survived the rollback
Checkout's first sequence was four boxes: controller, service, database, email, with solid arrows and no returns. The service called the mail sender and then inserted the order, and a unique-key failure on retry threw after the mail had gone. Learners forwarded the email to support. The row did not exist. The redrawing in this lesson puts authorize, then save, then commit, then the listener. The mail lifeline is absent from the first diagram on purpose, so a reviewer cannot "helpfully" drag it above the commit. The week after the change, a provider timeout was added as its own alt operand labeled unknown, returning 409-style "retry with the same id" rather than 201. Support's script changed from "trust the email" to "trust the order id lookup." The diagram was the script.
What the squad still got wrong on the second drawing: they annotated the listener async while onPlaced ran on the request thread and called SMTP directly. A mail stall showed up as checkout latency. The correction was to rename the arrow synchronous-after-commit, then, once the latency budget required it, to move ReceiptSender behind a queue. The picture and the thread dump had to match. An open arrowhead is a claim about threads, not a decoration.
The activity with one happy diamond
A product review used an activity that was a straight line from "pay" to "enroll." The only diamond was "card ok?" with a yes edge. The no edge was missing. When the provider started returning timeouts, engineers followed the yes edge because the activity had nowhere else to put a slow call, and they committed orders, then tried to repair captures by hand. The activity was redrawn with three guards: authorized, declined, unknown. Unknown joined the same final node as declined, labeled "no entitlement." The database lane lost the action "create entitlement" on that path. Enrollment is a row in our database, written in the same transaction as the order, not a call the payment provider makes back. Putting "enroll" in the payment lane had made a remote system the owner of a local invariant. The partition fix was as important as the extra guard.
Best Practices
- Name lifelines as objects you can point to in a package. Include the transactional proxy when commit is part of the argument.
- Draw return arrows for every synchronous call that decides a branch.
- Put guards on
altoperands that match exception types or result fields in the code. - Keep business rules off the database lifeline unless a constraint is the enforcement. Then the message is "unique violation," not "compute the discount."
- Place commit and rollback as messages to
PlatformTransactionManager, and place HTTP status messages after them. - Draw the receipt after commit, and make the arrowhead tell the truth about whether the request thread waits.
- Prefer one sequence per risky use case. Extract a
reffor pricing if the main diagram no longer fits on a screen. - Use an activity when the audience is decisions and lanes. Use a sequence when the audience is call order and transactions.
- Do not draw
paror a fork without a failure story for each side. - Cover every fragment with a test that would fail if the arrow moved.
- When
@Transactionalis on a method, the incoming arrow starts at a different bean. - Note
fallbackExecutionand rollback rules in a sentence under the diagram. Defaults surprise people who only read the arrow.
Common Mistakes and Anti-Patterns
Success before commit. The controller lifeline returns 201 on the arrow into save, with commit nowhere. A crash after the response and before commit loses the order the client will not retry. Move the response below commit.
Email inside the transaction. A synchronous mail message above commit. The connection stays checked out, and a rollback can follow a sent message or a sent message can follow a rollback, depending on order. Move mail to AFTER_COMMIT.
Self-invocation drawn as a transaction boundary. An arrow from an object to itself labeled @Transactional. The proxy is not on that path. Make the entry method the transactional one, called from the controller's bean.
A single unguarded happy path. No alt for decline, timeout, or duplicate key. The code grows an if the diagram never owns. Add operands for the outcomes you handle, and delete outcomes you do not.
par around charge and save. Two parallel regions, one database, no lock, a charge with no row or a row with no charge. Keep them sequential and name the compensation or the idempotency key.
Layer lifelines. "UI," "Service," "DB" with every rule inside Service. You cannot see that the controller chose the status or that the store only inserts. Split the lifelines that make different decisions.
Activity action in the wrong partition. "Create entitlement" drawn in the payment provider's lane. The provider does not own your enrollment table. Put the action in the database lane and the request in the domain lane.
Async decoration. Open arrowheads on blocking calls. The thread dump will disagree with the review. Change the code or change the arrow.
Troubleshooting and Design Review
Symptom: Learners have receipts and no order row.
Likely cause: mail is sent before commit, or commit is skipped on a path that still notifies.
Investigation: find ReceiptSender or JavaMailSender relative to commit in the sequence and in the stack. Check whether PaymentDeclined is a checked exception with default rollback rules.
Corrective action: publish after a successful save, listen AFTER_COMMIT, roll back on decline. Add the test that decline publishes nothing.
Symptom: Checkout latency tracks the mail provider, and pool metrics show busy request threads, not busy JDBC connections.
Likely cause: the listener runs on the caller thread after commit and blocks on SMTP. The diagram called it async.
Investigation: thread dump during a stall. Look for JavaMailSender or an HTTP client on http-nio threads.
Corrective action: either accept the coupling and fix the arrow, or move send onto a queue and draw a new sequence for the consumer.
Symptom: @Transactional methods leave rows uncommitted when called from a scheduled job inside the same class.
Likely cause: self-invocation. The sequence shows an internal arrow.
Investigation: whether the caller and the callee are the same bean instance.
Corrective action: call through the injected proxy or move the transactional method to the bean the scheduler calls from outside.
Symptom: Retries double-charge.
Likely cause: authorize has no idempotency key on the message, and unknown timeouts still create a new payment attempt with a new key.
Investigation: the authorize arguments in the diagram and in the provider request log.
Corrective action: key equals order id. Unknown outcomes retry the same key. Do not draw a second authorize with a fresh identifier on the retry sequence.
Symptom: An activity review approved a design the sequence cannot implement.
Likely cause: the activity joins the timeout edge into commit, or a partition is missing.
Investigation: walk each guard to a final node and name the lifeline that would perform it.
Corrective action: redraw the edge. Prefer the sequence as the source of truth for commit, and the activity as the source of truth for who decides.
Review questions: On which message does commit happen? What is the HTTP status if that message fails? Which fragment is untested? Is the receipt arrow blocking? Does any arrow enter a @Transactional method from this? What is the idempotency key on authorize? Which lane owns the entitlement row?
Interview Questions and Answers
Q1. What does a sequence diagram show that the class diagram from lesson 12 does not?
The class diagram shows that a ticket can refer to a spot and that a floor owns spots. The sequence shows the order of messages for one use case: check, occupy, save, commit, and what returns if occupy throws. I use the class diagram to reject illegal structure and the sequence to reject illegal order, especially around commit and external calls. If the interviewer has time for one follow-up, I draw the failure operand, not a second happy path.
Q2. Where is the Spring transaction on a checkout sequence?
The controller calls a proxy, the proxy calls PlatformTransactionManager.getTransaction, then the target PlaceOrder.place, then commit or rollback before the controller writes the status. I name DataSourceTransactionManager for JDBC. I do not draw the annotation as magic on the database lifeline. If the call is this.place() from the same bean, I say there is no transaction, because the proxy was skipped.
Q3. Why does rollback behavior depend on the exception type?
Spring's default @Transactional rolls back for RuntimeException and Error, and commits if the method completes by throwing a checked exception. I make PaymentDeclined unchecked, or I set rollbackFor, and I make the alt guard the same choice. A diagram that says rollback while the code throws a checked exception will leave the row in place. I have seen that mismatch in a review, and the fix was the type hierarchy, not the SQL.
Q4. How do you show a receipt without risking a rollback after the email?
I publish OrderPlaced inside the transaction and handle it with @TransactionalEventListener(phase = AFTER_COMMIT). The listener is a separate lifeline. The message to it is after commit. A send failure is logged and does not change the 201. I also state fallbackExecution: the default false means a publish outside a transaction notifies nobody. I do not draw a synchronous mail call above commit and label it "best effort."
Q5. When is the receipt arrow asynchronous?
When the request thread does not wait. A listener that runs on the caller thread after commit and calls SMTP is still synchronous with respect to the HTTP response; it is only ordered after commit. I use an open arrowhead when a queue or @Async executor takes the work and the controller can return without it. I can defend either design. I cannot defend an arrowhead that lies about the thread.
Q6. What combined fragment do you use for decline, and what do you refuse?
alt with guards [authorized], [declined], and [unknown] if timeout is possible. Each guard has a test and an HTTP result. I refuse par for authorize and save. They are ordered because a charge without a row and a row without a charge are different incidents, and parallel fragments do not say which one I accept. I also refuse an opt around save. Saving is not optional on the authorized path.
Q7. How does an activity diagram earn its place beside a sequence?
I draw the activity when the argument is which tier decides, using partitions for learner, web, domain, payment, and database. I draw the sequence when the argument is proxy, commit, and message order. I do not maintain two pictures that disagree. If the activity joins timeout into the commit action, the sequence is the one I repair first, then I fix the activity to match. Product readers can use the activity. I do not ask them to read DispatcherServlet.
Q8. What is wrong with lifelines named UI, Service, and Database?
They hide the decisions. The controller chooses 201 versus 402. The use case chooses to authorize before save. The store inserts and does not price. A single Service lifeline becomes the god class from lesson 11, drawn as a tall activation. I split lifelines where responsibility changes, and I stop before every private method has a box.
Q9. A retry returns and the learner is charged twice. What was missing on the diagram?
The authorize message had no idempotency key, and the unknown operand was absent, so a timeout looked like a reason to send a new charge with a new key. I put PaymentKey equal to the order id on the authorize call, I return a non-201 for unknown, and I tell the client to retry the same id. The store's primary key makes the second insert a 409 instead of a second order. Full exactly-once claims are a later lesson. The sequence's job is to show the key and the operand.
Q10. How do you test a sequence diagram?
Each operand is a test. Decline does not call save and does not publish. Authorized save is followed by an event, and a listener test sees the event only after commit. A web test checks the status codes. I do not encode the diagram in a string and parse it. I encode the observable messages: interactions on fakes, rows, and HTTP status. If I can move an arrow and no test fails, the fragment was decoration.
Q11. What do you leave off a 45-minute checkout sequence?
RequestMappingHandlerAdapter, JSON field names, and the connection pool. I keep the servlet or the controller, the proxy, the transaction manager, the authorizer, the store, and the after-commit listener. I label commit, rollback, and the idempotency key. I mention filters only to say the principal is already set. Depth on doDispatch is for a binding bug, not for a money-flow interview.
Hands-On Exercises
Exercise: Checkout sequence and activity
Problem statement. Specify course checkout so authorization, commit, HTTP status, and receipt are ordered safely. The client sends the order id. Payment decline must not leave a row. A receipt must not be sent if the transaction rolls back. A timeout is not a success.
Tasks.
- Draw a sequence from the HTTP request through
DispatcherServletor the controller, the transactional proxy,PlatformTransactionManager, the authorizer, and the store. Includealtfor authorized, declined, and unknown. - Draw the receipt as a second interaction that starts at
AFTER_COMMIT. Choose a blocking or queued send and make the arrowhead match. - Draw an activity with partitions. Timeout must not reach the commit action.
- Implement
PlaceOrder.placewith@Transactional, a runtime decline exception, andpublishEventafter save. - Implement a listener with
TransactionalEventListenerand a failure path that does not throw away the order id. - Write one test that a decline neither saves nor publishes.
- State the idempotency key on the authorize message and the HTTP status for a duplicate order id.
- Note the self-invocation bug as a short incorrect sequence and say why the proxy never sees it.
Expected deliverables. Two sequence diagrams, one activity, the Java for the use case and listener, the decline test, and the idempotency note.
Evaluation rubric.
| Assessment area | Weight | What "good" looks like |
|---|---|---|
| Commit order | 25% | 201 is below commit; decline and unknown roll back or never insert |
| Fragments | 20% | Guards match exceptions and tests |
| Receipt | 15% | After commit, arrowhead matches threading |
| Spring mapping | 15% | Proxy and transaction manager are on the successful sequence |
| Activity lanes | 15% | Payment does not own the enrollment row; timeout has an edge |
| Retry | 10% | Authorize carries a stable key |
A straight line of boxes with no returns and no rollback does not pass, even if the class names are correct.
Lesson Summary
Sequence diagrams order messages among objects. Activity diagrams place decisions in lanes. In a Spring checkout the messages that matter are the call into the transactional proxy, authorize with a stable key, save, commit or rollback, the HTTP status after that completion, and a receipt listener that runs only after commit. Combined fragments are the tests you mean to keep. Open arrowheads are claims about threads. Self-invocation is a sequence that never touches the proxy. The campus incident was a receipt drawn, and sent, on the wrong side of commit. The repair was to move the arrow and to make unknown a guard that does not enroll anyone. Lesson 14 asks when a named pattern is the right way to change these objects, and when the sequence is already enough.
Further Reading
- Object Management Group, UML 2.5.1 — Interactions and Activities — lifelines, combined fragments, partitions, and activity nodes.
- Martin Fowler, UML Distilled — the short discipline of sequence versus communication diagrams, and of drawing returns when they matter.
- Spring Framework, Web MVC servlet stack —
DispatcherServlet, handler mappings, and handler adapters. - Spring Framework, declarative transactions — rollback rules, proxy boundaries, and self-invocation.
- Spring Framework, transaction-bound events —
@TransactionalEventListenerandTransactionPhase.AFTER_COMMIT. - Primers: /learn/low-level-design/sequence-diagrams, /learn/low-level-design/activity-diagrams.
- This course: /learn/advanced-system-design/uml-class-and-object-diagrams, /learn/advanced-system-design/solid-principles-in-practice, /learn/advanced-system-design/idempotency-and-exactly-once-processing, /learn/advanced-system-design/design-patterns-overview.