From Requirements to Technical Design
The questions a technical design answers, from use cases and invariants to transactions, failures and risks.
Katselet ilmaista esikatseluoppituntia.
In Module 1, before writing production code, we understood what the business wants, which behaviours are required and which are out of scope, which engineering constraints exist, when a change is Done, and what the initial backlog looks like. We now have enough context about the problem. The next question is:
How should we design the system so that it satisfies those requirements?
This is where technical design begins. Its purpose is not impressive diagrams or many architecture patterns, but to make important technical decisions clear enough before implementation that the team builds one coherent system. In this lesson, we will learn which questions the design must answer. We will not finalize the database schema, package structure or Java classes yet.
Requirements Tell Us What; Design Decides How
We have a requirement: customers can create an Order containing one or more available Products. We also know that inventory must be sufficient, the backend determines prices, Order creation is all-or-nothing and purchase-time prices must be preserved. These requirements tell us what behaviour the system must provide.
They do not tell us which components exist, where Order creation logic lives, how Product and Inventory interact, what is persisted, what behaviour belongs inside Order, or where transaction boundaries are. Those are technical-design questions. A requirement answers "What must happen?"; technical design answers "How should our system make it happen?"
Technical Design Is More Than an Architecture Diagram
When engineers hear "technical design", they often think of boxes and arrows, such as handler → UseCase → Repository → Database. That may be part of the architecture, but useful technical design answers deeper questions: What are the core domain concepts, and who owns each responsibility? Which rules must always hold? Where do transactions begin and end? Which data must be a historical snapshot? Which boundaries exist, how are failures handled, which alternatives did we consider and what are the risks?
Drawing boxes is easy; assigning responsibilities correctly is harder.
Start With the Problem, Not the Framework
A weak starting point would be: "We use Spring Boot, therefore we need a Controller, Service, repository, Entity, DTO and Mapper." This begins with framework structure rather than the problem. A better sequence asks what business behaviour we are implementing, then what responsibilities it requires, then how they interact, and only after that how we express the design with Java, Spring Boot and PostgreSQL. The framework should support the design; the design should not exist merely to resemble a Spring Boot tutorial.
Technical design does not start from a blank page either. Its inputs are the product requirements, the acceptance criteria, the engineering context from Module 1 and the non-functional concerns: security, data integrity, reliability, maintainability and observability. A good design satisfies all of them together.
Good Design Is Constraint Satisfaction
Requirements, constraints, risks and trade-offs together produce the technical design. Order creation must be all-or-nothing, and our engineering context says Orders and Inventory belong to the same PostgreSQL-backed application. That gives us a design clue: a local database transaction is likely an appropriate mechanism. We are deriving the solution from the problem, not saying "Transactions are useful, so let's find somewhere to use them."
Identify the Core Use Cases First
Before creating classes, tables or packages, identify the workflows. Our major v1 use cases are Create Product, Update Product, Deactivate Product, Browse Products, Manage Inventory, Create Order, View Order History, View Order Detail, Cancel Order and Pay Order. Use cases expose application responsibilities more clearly than framework components do.
From our acceptance criteria, Create Order conceptually needs to:
- Receive the requested items and identify the customer
- Validate the request
- Load the referenced Products and ensure they are active
- Validate quantities
- Consume available inventory safely
- Read authoritative Product prices and capture them as purchase-time prices
- Create the Order and persist it with its OrderItems
This is not implementation yet. It is a behavioural workflow, and now we can ask: Which responsibility belongs where?
Responsibilities Before Classes
The workflow reveals responsibilities: represent an Order and an OrderItem, decide whether an Order can be cancelled or paid, calculate an Order total, retrieve Product information, consume and restore inventory, persist Orders, coordinate Order creation. Later, they may become domain objects, use cases, repositories, handlers or external services, but responsibility comes before class naming. Responsibilities are also more stable than class names: we might first imagine an OrderService, then prefer CreateOrderUseCase and CancelOrderUseCase, while the responsibilities stay the same.
Domain Concepts Before Tables
The requirements keep referring to Product, Inventory, Order, OrderItem, Payment and customer identity. These are useful domain concepts, but not every noun becomes an entity, a table or a Java class; we reason about identity, lifecycle, ownership and behaviour. Customer identity matters to Orders, yet that does not mean we need a local Customer entity.
A common mistake is beginning with tables such as products and order_items. A schema is only one representation of the domain; a better sequence goes from business concepts to relationships and rules, the domain model, the persistence model and finally the schema. Starting with tables models storage before behaviour.
Thinking only in persistence terms, an orders row is just id, customer_id and status, and we may end up calling order.setStatus(OrderStatus.CANCELLED) from anywhere. But cancellation has rules (only UNPAID Orders can be cancelled), so Order is not simply persisted data; it has behaviour and invariants.
Identify Invariants
Technical design identifies the rules that must remain true:
- An Order contains at least one OrderItem.
- OrderItem quantity is positive, and
unitPriceCentsis non-negative. - Successful Order creation must not consume more inventory than is available.
- The Order total equals the sum of the OrderItem totals.
- A
CANCELLEDOrder cannot be paid, and aPAIDOrder cannot be cancelled in v1. - A customer cannot operate on another customer's Order.
Different invariants need different enforcement. Positive quantity is checked at request validation and in domain state; Order ownership depends on authenticated identity and belongs to security and application access; "never oversell" needs persistence-level concurrency reasoning. There is no universal rule such as "all validation goes in the handler" or "all business rules go in one service".
Identify Transactional Workflows
Some operations modify several pieces of local state together. Order creation consumes inventory, creates the Order and persists the OrderItems; our acceptance criteria say it is all-or-nothing, so technical design needs a transaction boundary:
BEGIN
validate current state
consume Inventory
persist Order
persist OrderItems
COMMIT (on any failure: ROLLBACK)
Cancellation is not simply order.cancel() either. It loads the customer-owned Order, validates its state, transitions it to CANCELLED, restores the consumed inventory and persists the changes. These must succeed or fail together; otherwise an Order could be CANCELLED while its inventory was never restored.
Payment is different, because it crosses an external system boundary. A PostgreSQL transaction cannot roll back a successful provider-side payment, so a local transaction cannot make the whole payment workflow atomic:
External side effects require different reasoning from local database mutations.
We will design this carefully in the external-services module.
System and Internal Boundaries
Our engineering context set the system boundaries: the identity capability authenticates users, and our backend stores its state in PostgreSQL and calls the payment provider. Inside, one deployable application still needs logical separation (Product, Inventory, Order, Payment, identity context) so that it does not become one giant undifferentiated codebase. These are code-organization boundaries inside one application, not microservices.
More layers do not automatically mean better architecture either: a controller, facade, application service, manager, processor, DAO, mapper, adapter and gateway for a simple feature is as unhelpful as one class that does everything. Our question is:
What is the simplest structure that keeps responsibilities understandable and protects required behaviour?
Data Ownership and Lifecycles
For every fact, ask: Who owns this fact? Product owns the current price, Inventory the available quantity, OrderItem the purchase-time unit price. The Order lifecycle needs the same care: which states exist (for v1 only UNPAID, PAID and CANCELLED), which transitions are valid, what causes each one and which are forbidden. We do not add future states "just in case", such as SHIPPED or REFUNDED. The next lesson examines each concept, its relationships and its lifecycle in depth.
API Boundaries Should Follow Use Cases
The application uses REST/JSON, with likely resources /products, /inventory and /orders. The use cases suggest the Order operations:
| Use case | Endpoint |
|---|---|
| Create Order | POST /api/v1/orders |
| View Order history | GET /api/v1/orders |
| View Order detail | GET /api/v1/orders/{orderId} |
| Cancel Order | POST /api/v1/orders/{orderId}/cancel |
| Pay Order | POST /api/v1/orders/{orderId}/pay |
The database shape must not leak into the API: an order_items table does not imply a POST /api/v1/order-items endpoint. The user operation is "create an Order containing items", and API design reflects business operations, not table CRUD.
The same reasoning rules out client-orchestrated transactions, where the client would GET the Product price, GET the inventory, PATCH the inventory, POST the Order and then POST each OrderItem. Many things can fail between those requests: the client may stop after the inventory mutation, two clients may race, and the client may use stale pricing. Instead, POST /api/v1/orders is one backend-owned business workflow.
Concurrency Must Be Recognized During Design
Suppose available inventory is 1 and requests A and B each want 1. A naive implementation lets A read 1, B read 1, both validate, then A and B each subtract 1, which violates inventory correctness. "Check then update" alone is unsafe:
<!-- fragment -->if (inventory.availableQuantity() >= requestedQuantity) {
inventory.decrease(requestedQuantity);
}
The check can become stale before the write commits, so our design needs concurrency-safe inventory mutation at the persistence boundary. Atomicity matters across items too: if an Order contains Product A × 2 and Product B × 3, and B's inventory is insufficient after A was consumed, no Order is created and no inventory remains consumed. Some rules also deserve database constraints as an additional integrity boundary; module 6 decides which (6.02–6.04).
Failures Are Part of Design
Expected failures, such as Product not found, insufficient inventory or Order not cancellable, should not reach API clients as random Java or persistence exceptions. The architecture distinguishes an expected business failure (the requested quantity exceeds the available inventory, and the application is behaving correctly) from an unexpected system failure (PostgreSQL is unavailable, or a programming error occurs). The distinction affects HTTP mapping, logging, metrics, retry behaviour and incident investigation; the public error format comes later.
For every workflow, ask: Where can this fail?
- Order creation: invalid request, Product missing or inactive, duplicate Product, invalid quantity, insufficient inventory, database failure.
- Cancellation: Order missing, wrong customer, Order already
CANCELLEDorPAID, inventory integrity problem, database failure. - Payment: Order missing, wrong customer, Order not payable, provider rejection, provider timeout, provider success with local failure, database failure.
Do not choose failure policies prematurely. A provider timeout does not mean we decide now to retry three times; first identify the failure mode, later decide whether a retry is safe, idempotent or ambiguous.
Payment, Identity and Authorization Boundaries
Payment and identity are both boundaries the design must name. The payment provider's URLs, JSON, status codes and credentials should not appear in Order workflow code, and the domain should not depend on token structure or identity-provider details: customer workflows receive a resolved CustomerId. Lesson 2.03 assigns these responsibilities.
Read and Write Patterns
Browsing Products and Order history are list operations, so retrieval is bounded and pagination will shape API and persistence design. Writes differ too: renaming a Product is simple, creating an Order is transactional and paying is cross-system, so not all writes are designed the same way.
Levels of Design
Design works at different levels: the system (one backend application, PostgreSQL, the identity capability, the payment provider), the application (Product, Inventory, Order, Payment, identity context), the workflow (how Create, Cancel and Pay Order work) and the implementation (Java classes, Spring annotations, JPA mappings, method signatures). Good design moves from high level to low level intentionally.
So do not jump to method signatures too early. OrderResponse createOrder(String customerId, List<OrderItemRequest> items) looks concrete, but it hides open questions: What is CustomerId? What errors can occur? Where does the transaction live? Does the HTTP representation belong inside the use case? Signatures come after responsibilities.
"Isolate payment behind an integration boundary" is an architecture decision; the name of the provider client class or a package is an implementation detail, and ADRs capture only the former. Good design describes responsibilities, flows, boundaries, state transitions, data ownership, transactions, failure modes, risks and major decisions, without fixing every private method too early.
Design Is Communication and Review
If two engineers disagree about the inventory concurrency strategy, comparing the options is cheaper before hundreds of lines of code exist. Technical design lets engineers, reviewers and future maintainers understand what we propose, why, which risks exist and which alternatives were rejected.
Design effort should match risk. A tiny endpoint may need only a ticket and an implementation note. Our backend has multiple domain concepts, transactions, inventory concurrency, authorization and external payment integration, which justifies an RFC. We will not fill an RFC template mechanically, though: we first work out the domain entities, system responsibilities and initial architecture, and only then write the RFC, its ADRs and the design review, which is the order of the rest of Module 2.
Alternatives and Non-Goals
Should Product contain the available quantity, or should Inventory be a separate concept identified by ProductId? Both could work. We compare such options by responsibility, business meaning, change patterns, requirements and complexity, not by which looks more enterprise, and a good design explains why the chosen benefit is worth its cost (the next lesson and ADR-002 make this particular choice). We also state non-goals explicitly, such as shipping, discounts, refunds or a distributed architecture; lesson 2.04 records the infrastructure we deliberately leave out. Non-goals prevent review drift: asked "How will this support 50 warehouses?", the answer is that it does not need to.
Simplicity, Changeability and YAGNI
We want the minimum necessary complexity, which is neither the minimum number of files nor the maximum number of abstractions. Simplicity means another engineer can build an accurate mental model of the system without unnecessary indirection. Optimize for changeability, not imaginary futures.
YAGNI ("You Aren't Gonna Need It") warns against speculative functionality. It does not mean ignoring transactions, failures, concurrency or responsibility boundaries, which current correctness needs:
| Overengineering | Necessary engineering |
|---|---|
| Add Kafka because commerce systems often use Kafka | Ensure Order creation cannot leave inventory consumed without an Order |
| Build a generic payment-provider plugin framework | Keep provider HTTP details outside Order business logic |
Traceability and Risks
Each design decision should trace back to a requirement: OrderItem stores its price because historical pricing must not change, Order creation uses one local transaction because it is all-or-nothing, Order access is scoped by CustomerId because customers may access only their own Orders. That makes decisions easier to review. The design also starts an initial risk register:
- Inventory concurrency: concurrent Orders consuming the same remaining stock.
- Payment partial failure: the payment succeeds while the local Order update fails.
- Authorization mistakes: one customer's Orders exposed to another.
- Historical data drift: Product changes rewriting the meaning of past Orders.
- Scope expansion: drift into shipping, refunds, discounts and warehouses.
A risk is not an existing bug: payment partial failure is known because we cross an external boundary. Risk tells us where design attention is needed.
Our Initial Design Direction
The direction is now clear: one Spring Boot application with logical areas for Product, Inventory, Order, Payment and the identity context; state in PostgreSQL; workflows in use cases; persistence through repositories; payment behind a dedicated integration boundary; and transactions for local multi-state operations. It is direction, not final structure. Which concepts are entities, what exactly each owns, the package architecture and which decisions deserve ADRs are the subjects of the next lessons.
A Technical Design Is a Hypothesis
Technical design is our best current hypothesis for how to satisfy known requirements.
It is not sacred. When an assumption proves wrong, a query performs poorly, a boundary is awkward or a requirement was misunderstood, the design changes, but not randomly. A healthy revision reads: we used approach A for inventory, found that it cannot preserve inventory correctness under concurrency, and switched to approach B to protect the invariant. That is evidence-driven evolution, not "I saw a new architecture pattern and rewrote everything." Design and backlog influence each other too: design may show that "Implement Order Creation Workflow" should be split, or that concurrency-safe inventory consumption needs its own ticket.
Practice Exercise 1: Design Two More Use Cases
Solutions: lesson 2.08.
Module 2 has no code yet, so its exercises produce design documents. Keep them in the workspace you started in lesson 1.01 (for example backend-course/), in a new docs/practice/ folder, apart from the team's docs/rfcs/ and docs/adr/. The order-management repository itself starts in module 3.
In docs/practice/2.01-use-cases.md, do for View Order Detail and Deactivate Product what this lesson did for Create Order:
- Write the behavioural workflow as numbered steps, without class names.
- List the invariants and rules each one touches.
- Answer "Where can this fail?" for each.
- Say whether it needs a transaction boundary, and why.
- Write one traceability line per decision ("X, because requirement Y").
Practice Exercise 2: Necessary or Speculative?
For each proposal, decide whether it is necessary engineering for v1 or overengineering, and name the requirement, risk or missing requirement behind your answer:
- Cache Product browsing in Redis.
- Run Order creation in one local database transaction.
- Add
SHIPPEDto the Order states now, because shipping will come one day. - Return Order history in pages instead of all Orders at once.
- Build a plugin framework so a second payment provider can be added without code changes.
- Keep the payment provider's JSON and status codes out of the Order workflow.
Practice Exercise 3: Find the Problems in a Design Note
A teammate writes this first design note. Find at least four problems and say which part of the lesson each one contradicts:
We use Spring Boot, so each feature gets a Controller, Service, Repository,
Entity, DTO and Mapper. The client loads the Product price with
GET /api/v1/products/{id} and sends it, with the total, in POST /api/v1/orders.
Order items are added afterwards with POST /api/v1/order-items. Payment and
the Order update run in one @Transactional method, so payment is atomic.
If the provider times out, we retry three times.
Knowledge Check
Question 1
Each request in a client-orchestrated flow (GET the price, PATCH the inventory, POST the Order) is validated by the backend. Why is the flow still unsafe?
Question 2
Why can a PostgreSQL transaction make Order creation all-or-nothing but not Pay Order?
Question 3
The check if (inventory.availableQuantity() >= requestedQuantity) is correct when one request runs at a time. What goes wrong when two run together?
Question 4
YAGNI warns against building what we do not need yet. Why does it not argue against designing transaction boundaries now?
Question 5
The design lists "provider timeout" as a failure mode of Pay Order but does not choose a retry policy. Why wait?
Knowledge Check Answers
Answer 1
The backend sees independent requests, not one business operation. The client can stop after the inventory change, two clients can race between the steps, and the price the client read can be stale by the time it orders.
Answer 2
Order creation changes only state in our own database, which a rollback can undo. Payment also changes state at the provider, and a local rollback cannot undo a charge the provider has already made.
Answer 3
Both requests can read the same quantity before either writes, both pass the check, and both decrease it, so more is consumed than was available. The check is stale by the time the write commits.
Answer 4
YAGNI rejects speculative features. All-or-nothing creation is a current requirement, and the transaction boundary is what makes it true, so it is part of correctness, not a guess about the future.
Answer 5
Whether a retry is safe depends on facts we do not have yet, such as whether the provider deduplicates requests. A timeout can mean the charge happened, so a blind retry could charge twice; first we name the failure, then the provider contract decides the policy.
Engineering Principle
Start technical design from business behaviour and system constraints—not from framework annotations, database tables, or fashionable architecture patterns.
Requirements define the behaviour we owe the user; design decides how responsibilities, data, boundaries, transactions and failure handling provide it, with the minimum complexity that protects the known behaviour and risks.
Summary
- Technical design decides how the system provides the required behaviour, starting from requirements and constraints rather than from the framework.
- Use cases, responsibilities and invariants come before classes and tables; different invariants need different enforcement.
- Order creation and cancellation are local transactions; payment crosses an external boundary a local transaction cannot roll back, and inventory concurrency is recognized up front.
- APIs expose business operations, not tables or client-orchestrated steps, and expected business failures stay distinct from system failures.
- Real alternatives, explicit non-goals and a risk register keep design focused; YAGNI rejects speculation, not correctness.
- A design is a hypothesis that evolves with evidence and together with the backlog.
We have stayed deliberately above code-level detail. In the next lesson, Identifying Domain Entities, we analyze Product, Inventory, Order, OrderItem, CustomerId and the payment boundary to decide which concepts have identity and lifecycle, which are value-like, what each owns, and which relationships and invariants belong in the domain model.
Mieti ensin
Tulossa pianVälikysymyksiä, joissa valitset vastauksen ennen kuin oppitunti näyttää sen.