Designing the System
From Requirements to Technical Design
In Module 1, we did something important before writing production code.
We understood:
- what the business wants
- what users need to be able to do
- which behaviours are required
- which behaviours are out of scope
- which engineering constraints already exist
- when a change can be considered Done
- what the initial engineering 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.
The goal of technical design is not to create impressive diagrams or introduce as many architecture patterns as possible.
Its purpose is to make important technical decisions clear enough before implementation so that the team can build one coherent system.
In this lesson, we will learn how to move from requirements into technical design.
We will not finalize the database schema, package structure, or Java classes yet.
First, we need to understand which questions the design must answer.
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:
Inventory must be sufficient.
The backend determines prices.
Order creation is all-or-nothing.
Purchase-time prices must be preserved.
These requirements tell us what behaviour the system must provide.
They do not tell us:
Which application components should exist?
Where should Order creation logic live?
How should Product and Inventory interact?
What should be persisted in PostgreSQL?
What behaviour belongs inside Order?
Where should transaction boundaries exist?
How should external Payment logic connect
to Order workflows?
Those are technical-design questions.
Conceptually:
Requirement
↓
What must happen?
and:
Technical Design
↓
How should our system make it happen?
Technical Design Is More Than an Architecture Diagram
When engineers hear technical design, they often think about boxes and arrows.
For example:
Handler
↓
UseCase
↓
Repository
↓
Database
That may be part of the architecture.
But useful technical design answers deeper questions:
What are the core Domain concepts?
Who owns each responsibility?
Which rules must always remain true?
Where do transactions begin and end?
Which data must be persisted?
Which data must be historical snapshots?
Which system boundaries exist?
How are failures handled?
Which decisions are difficult to reverse?
Which alternatives did we consider?
What are the known 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:
Controller
Service
Repository
Entity
DTO
Mapper
This begins with framework structure rather than the problem.
A better sequence is:
What business behaviour are we implementing?
Then:
What responsibilities are required?
Then:
How should those responsibilities interact?
And only after that:
How do we express the design using
Java, Spring Boot, PostgreSQL,
and our engineering context?
The framework should support the design.
The design should not exist merely to resemble a Spring Boot tutorial.
Our Design Inputs
Technical design does not start from a blank page.
We already have several important inputs.
Product Requirements
Customers browse available Products.
Customers place Orders.
Customers view Order history.
Customers cancel eligible Orders.
Customers pay through an external Payment provider.
Administrators manage Products and Inventory.
Acceptance Criteria
For Order creation:
An Order contains at least one item.
Referenced Products must exist.
Products must be orderable.
Quantities must be positive.
Inventory must be sufficient.
The backend determines authoritative prices.
Purchase-time prices remain historical.
Any invalid item causes the whole operation to fail.
Engineering Context
Java
Spring Boot
Gradle
REST / JSON
PostgreSQL
Flyway
Existing identity capability
External Payment provider
Containerized deployment
Non-Functional Concerns
security
data integrity
reliability
maintainability
observability
A good technical design has to satisfy all of these together.
Good Design Is Constraint Satisfaction
A useful way to think about design is:
Requirements
+
Constraints
+
Risks
+
Trade-offs
↓
Technical Design
Consider:
Order creation must be all-or-nothing.
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.
We are not saying:
Transactions are useful,
so let's find somewhere to use them.
Design Decisions Need Reasons
Suppose we decide:
OrderItem stores its purchase-time unit price.
Why?
Because:
Historical Order prices must not change
when the Product's current price changes.
The reasoning is traceable:
Requirement
↓
Historical pricing must remain stable
↓
OrderItem must preserve
purchase-time unitPriceCents
This kind of reasoning later belongs in design documentation.
Identify the Core UseCases First
Before creating classes, tables, or packages, identify the workflows the backend must support.
Our major v1 UseCases include:
Create Product
Update Product
Deactivate Product
Browse Products
Manage Inventory
Create Order
View Order History
View Order Detail
Cancel Order
Pay Order
These UseCases expose application responsibilities more clearly than framework components do.
Example: Create Order UseCase
From our Acceptance Criteria, Create Order conceptually needs to:
Receive requested items
↓
Identify the Customer
↓
Validate the request
↓
Load referenced Products
↓
Ensure Products are active
↓
Validate quantities
↓
Consume available Inventory safely
↓
Read authoritative Product prices
↓
Capture purchase-time prices
↓
Create the Order
↓
Persist Order and OrderItems
This is not implementation yet.
It is a behavioural workflow.
Now we can ask:
Which responsibility belongs where?
Identify Responsibilities Before Classes
From the workflow, we can identify responsibilities such as:
Represent an Order.
Represent an OrderItem.
Determine whether an Order can be cancelled.
Determine whether an Order can be paid.
Calculate an Order total.
Retrieve Product information.
Consume Inventory safely.
Restore Inventory after cancellation.
Persist Orders.
Coordinate Order creation.
Later, those responsibilities may become:
Domain objects
UseCases
Repositories
Handlers
external Services
But responsibility comes before class naming.
Responsibilities Are More Stable Than Class Names
Suppose we initially imagine:
OrderService
Later, we decide the system is clearer with:
CreateOrderUseCase
CancelOrderUseCase
PayOrderUseCase
The class structure changes.
The underlying responsibilities remain:
coordinate Order creation
apply Product and Inventory rules
persist resulting state
Design discussions should focus on responsibilities first.
UseCase Is Our Workflow Vocabulary
For this course, internal application workflows use:
UseCase
Examples:
CreateProductUseCase
UpdateProductUseCase
SetInventoryUseCase
CreateOrderUseCase
GetOrderHistoryUseCase
CancelOrderUseCase
PayOrderUseCase
We reserve:
Service
for external or third-party integrations.
For example:
PaymentService
This distinction keeps internal orchestration separate from external integration concerns.
Start With Domain Concepts
The requirements repeatedly refer to concepts such as:
Product
Inventory
Order
OrderItem
Payment
Customer identity
These are useful Domain concepts.
But not every noun automatically becomes:
an Entity
a database table
a Java class
We need to reason about identity, lifecycle, ownership, and behaviour.
For example, Customer identity matters to Orders.
That does not mean we need a local Customer Entity.
Our application uses:
CustomerId
as authenticated application context.
Domain Model vs Database Model
A common mistake is beginning design with tables.
For example:
products
inventory
orders
order_items
payments
Some may eventually exist.
But a database schema is only one representation of the Domain.
A better sequence is:
Business concepts
↓
Relationships and rules
↓
Domain model
↓
Persistence model
↓
Database schema
If we start with tables, we may model storage before behaviour.
Order Is More Than a Database Row
If we think only in persistence terms:
orders
id
customer_id
status
created_at
we may end up with code like:
order.setStatus(
OrderStatus.CANCELLED
);
from anywhere.
But cancellation has rules:
Only UNPAID Orders can be cancelled.
PAID Orders cannot be cancelled in v1.
CANCELLED Orders cannot be cancelled again.
So Order is not simply persisted data.
It has behaviour and invariants.
Identify Invariants
Technical design should identify rules that must remain true.
Important examples:
An Order contains at least one OrderItem.
OrderItem quantity is positive.
OrderItem unitPriceCents is non-negative.
Successful Order creation must not consume
more Inventory than is available.
Order total equals the sum
of OrderItem totals.
A CANCELLED Order cannot be paid.
A PAID Order cannot be cancelled in v1.
A Customer cannot operate on
another Customer's Order.
These are core design inputs.
Different Invariants Need Different Enforcement
Consider:
quantity must be positive
We may enforce this at:
HTTP request validation
and when constructing Domain state.
Now consider:
Customer may access only their own Order.
This depends on authenticated identity and belongs to:
security + application access boundaries
Now consider:
Inventory must not be oversold
under concurrent Order creation.
That needs persistence-level concurrency reasoning.
There is no universal rule like:
all validation goes in the Handler
or:
all business rules go in one Service
Different rules need different boundaries.
Identify Transactional Workflows
Some operations modify several pieces of local state together.
Order creation involves:
consume Inventory
+
create Order
+
persist OrderItems
Cancellation involves:
transition Order to CANCELLED
+
restore consumed Inventory
Both have atomicity requirements.
From our Acceptance Criteria:
Order creation is all-or-nothing.
So technical design needs a transaction boundary.
Conceptually:
BEGIN
validate current state
consume Inventory
persist Order
persist OrderItems
COMMIT
If something fails:
ROLLBACK
The exact implementation comes later.
Cancellation Is Also Transactional
Cancellation is not simply:
order.cancel();
The workflow includes:
load Customer-owned Order
validate current state
transition Order to CANCELLED
restore consumed Inventory
persist changes
These state changes must succeed or fail together.
Otherwise we could get:
Order = CANCELLED
Inventory = not restored
which is inconsistent.
Payment Is Different
Payment crosses an external system boundary.
Conceptually:
PostgreSQL
+
External Payment Provider
A PostgreSQL transaction cannot roll back a successful provider-side Payment.
Therefore:
@Transactional
cannot make the complete Payment workflow atomic across both systems.
That gives us an important design principle:
External side effects require different reasoning from local database mutations.
We will design this carefully in the external-services module.
System Boundaries Matter
Our engineering context established:
Existing Identity Capability
↓
Order Management Backend
↓
Payment Provider
and:
Order Management Backend
↓
PostgreSQL
The Order Management Backend should not:
store user passwords
implement account recovery
own identity lifecycle
perform card-network processing
Those responsibilities belong elsewhere.
Clear boundaries protect scope.
Internal Boundaries Matter Too
One deployable application still needs logical separation.
Conceptually:
Product
Inventory
Order
Payment
Security / Customer Identity Context
These are not microservices.
They are logical responsibility boundaries inside one application.
Our goal is:
one deployable application
without:
one giant undifferentiated codebase
Modular Thinking Without Microservices
A useful conceptual view:
Order Management Backend
├── Product
├── Inventory
├── Order
├── Payment
└── Security / Identity Context
Product
Product identity
name
current price
active/inactive state
Inventory
available quantity
administrative quantity setting
Order-time consumption
cancellation restoration
Order
Order lifecycle
OrderItems
historical unit prices
derived total
Customer ownership
cancellation rules
Payment
Order Payment workflow
external provider boundary
Security / Identity Context
authenticated identity
CustomerId resolution
Administrator authorization
These are code-organization boundaries, not deployment boundaries.
Avoid Premature Layer Explosion
A Java application can quickly become:
controller
facade
application service
domain service
manager
handler
processor
repository
DAO
mapper
adapter
gateway
for a simple feature.
More layers do not automatically mean better architecture.
Each abstraction should solve a real problem.
Our question is:
What is the simplest structure that keeps responsibilities understandable and protects required behaviour?
Avoid One Giant Service Too
The opposite extreme is:
public class CommerceService {
createProduct();
updateInventory();
createOrder();
cancelOrder();
payOrder();
getOrderHistory();
}
This combines unrelated responsibilities.
As the codebase grows, such a class becomes difficult to:
understand
test
review
change safely
We want:
not unnecessarily fragmented
and:
not unnecessarily centralized
Domain Behaviour vs Workflow Coordination
Consider cancellation.
Some rules belong naturally inside Order:
Can this Order transition to CANCELLED?
Is the current status eligible?
The workflow also needs:
load the Order
enforce Customer ownership
restore Inventory
persist changes
Order should not connect to PostgreSQL or call Repositories.
This gives us:
Domain
↓
owns intrinsic business rules
and:
UseCase
↓
coordinates Domain,
Repositories,
and external dependencies
Example: Cancel Order
Conceptually:
CancelOrderUseCase
↓
load Customer-owned Order
↓
ask Order to cancel
↓
restore consumed Inventory
↓
persist changes
Order knows:
PAID cannot transition to CANCELLED.
The UseCase knows:
which Customer is making the request
and:
which Repositories participate
and:
Inventory restoration belongs
to the same local transaction
Data Ownership Must Be Clear
Ask:
Who owns this fact?
For example:
Current Product price
→ Product
Available quantity
→ Inventory
Purchase-time unit price
→ OrderItem
These values may look related, but they represent different facts.
Current Price vs Historical Price
Suppose:
Product P1
priceCents = 2000
A Customer creates an Order.
The OrderItem records:
Product P1
quantity = 2
unitPriceCents = 2000
Later:
Product P1
priceCents = 2500
The Product owns:
the current catalog price
The OrderItem owns:
the purchase-time historical price
These are different facts.
Duplicate Data Is Not Automatically Wrong
Someone may ask:
Why store price in both Product and OrderItem?
Because:
Product.priceCents
=
What does this Product cost now?
while:
OrderItem.unitPriceCents
=
What did this Product cost
when the Order was created?
This is not accidental duplication.
It represents different historical meaning.
Money Representation Is Already Decided
Our project uses:
Money
→ long cents
For example:
2000
→ 20.00 currency units
and:
8990
→ 89.90 currency units
PostgreSQL uses:
BIGINT
for these values.
We do not use:
double
float
BigDecimal
for authoritative monetary arithmetic in this project.
Identify Relationships
Conceptually:
CustomerId
1
│ owns
▼
*
Order
Order
1
│ contains
▼
*
OrderItem
OrderItem
*
│ references
▼
1
Product
Product
1
│ corresponds to
▼
0..1
Inventory
That final relationship matters.
A Product may exist without an Inventory record.
Such a Product is not orderable.
Product and Inventory Have Different Lifecycles
Questions include:
Can Product exist without Inventory?
Can Inventory exist without Product?
Can Inventory still be changed
after Product deactivation?
Does deactivation delete Inventory?
Our direction is:
Product may exist without Inventory.
Inventory cannot exist for
a nonexistent Product.
Inactive Product may still have Inventory.
Deactivation does not delete Inventory.
Product lifecycle and Inventory state are separate concerns.
Deactivation Does Not Rewrite History
If a Product becomes inactive:
future ordering is disabled
but:
historical Orders remain valid
The Product is not physically deleted.
OrderItem already preserves:
ProductId
quantity
unitPriceCents
so historical pricing does not depend on the Product's current price.
Design the Order State Transitions
Our v1 lifecycle contains only:
UNPAID
PAID
CANCELLED
Conceptually:
UNPAID
│
├── confirmed Payment ──▶ PAID
│
└── cancellation ───────▶ CANCELLED
And:
PAID
──X──▶ CANCELLED
Also:
CANCELLED
──X──▶ PAID
These transitions are more useful than inventing a long ecommerce state list.
State-Machine Thinking Without a Framework
We do not need a state-machine library.
But we should ask:
Which states exist?
Which transitions are valid?
What causes each transition?
Which transitions are forbidden?
For v1:
UNPAID → PAID
is valid after confirmed Payment success.
UNPAID → CANCELLED
is valid after cancellation.
PAID → CANCELLED
is unsupported.
CANCELLED → PAID
is invalid.
Do Not Add Future States “Just in Case”
Avoid:
PROCESSING
PACKED
SHIPPED
DELIVERED
RETURNED
REFUNDED
Our current Product does not support fulfilment or refunds.
Every extra state creates:
more transitions
more validation
more tests
more ambiguity
Design what the current scope needs.
API Boundaries Should Follow UseCases
We know the application uses REST/JSON.
Likely resources include:
/products
/inventory
/orders
The UseCases suggest operations such as:
POST /api/v1/orders
for Order creation.
GET /api/v1/orders
for Order history.
GET /api/v1/orders/{orderId}
for Order detail.
POST /api/v1/orders/{orderId}/cancel
for cancellation.
POST /api/v1/orders/{orderId}/pay
for Payment.
These expose meaningful application operations.
Do Not Let Database Shape Leak Into the API
A database table named:
order_items
does not imply a public endpoint:
POST /api/v1/order-items
The user operation is:
Create an Order containing items.
The backend should own that complete workflow.
API design should reflect business operations, not table CRUD.
Avoid Client-Orchestrated Transactions
This would be poor design:
1. GET Product price
2. GET Inventory
3. PATCH Inventory
4. POST Order
5. POST OrderItem
Many things can fail between those requests.
The client may stop after Inventory mutation.
Two clients may race.
The client may use stale pricing.
Instead:
POST /api/v1/orders
represents one backend-owned business workflow.
Design Persistence Around Behaviour
Persistence should support real UseCases.
Examples:
Find Product by ProductId
Find orderable Products
Read Inventory by ProductId
Consume Inventory if available
Restore Inventory
Save Order
Find Order by OrderId
Find Customer-owned Order
List Customer Orders
We should not invent generic Repository methods for hypothetical queries.
Persistence operations should emerge from application needs.
Repository Is an Application Boundary
UseCases should depend on meaningful Repository contracts.
Conceptually:
CreateOrderUseCase
↓
ProductRepository
InventoryRepository
OrderRepository
Repository implementations may use JPA or SQL internally.
The UseCase should focus on business workflow rather than persistence mechanics.
Database Constraints Can Reinforce Invariants
Some rules deserve persistence-level protection.
For example:
available_quantity >= 0
can be reinforced with:
CHECK (available_quantity >= 0)
Likewise:
quantity > 0
and:
price_cents >= 0
can be protected at database level.
Database constraints do not replace application validation.
They add another integrity boundary.
Not Every Rule Belongs in the Database
Consider:
A PAID Order cannot be cancelled.
That is a Domain transition rule.
Trying to encode every lifecycle rule purely as database constraints would make the design awkward.
Different invariants belong at different boundaries.
Multiple Layers Can Protect the Same Risk
For quantity:
HTTP validation
↓
reject obvious bad input
Domain validation
↓
prevent invalid Domain state
Database constraint
↓
protect persisted integrity
This is not automatically bad duplication.
Each boundary provides a different kind of protection.
Concurrency Must Be Recognized During Design
Suppose:
available Inventory = 1
Two requests arrive:
Request A wants 1.
Request B wants 1.
A naive implementation could do:
A reads 1.
B reads 1.
A validates.
B validates.
A subtracts 1.
B subtracts 1.
That can violate Inventory correctness.
So:
Do not sell more quantity
than is available.
has a concurrency consequence.
“Check Then Update” Is Not Enough
This alone is unsafe:
if (
inventory.availableQuantity()
>= requestedQuantity
) {
inventory.decrease(
requestedQuantity
);
}
The check can become stale before the database write commits.
Our design therefore needs:
concurrency-safe Inventory mutation
at the persistence boundary.
The exact implementation comes later.
Order Creation Is One Atomic Local Operation
Suppose an Order contains:
Product A × 2
Product B × 3
Inventory consumption for Product A succeeds.
Product B then fails because Inventory is insufficient.
The required result is:
No Order is created.
No Inventory remains consumed.
Therefore:
Order creation
=
one transactional unit
Error Handling Is Part of Design
Expected failures include:
Product not found
Product inactive
Insufficient Inventory
Order not found
Order not cancellable
Order not payable
These should not appear to API clients as random Java or persistence exceptions.
Architecture should distinguish:
expected business failure
from:
unexpected system failure
The exact public API error format comes later.
Business Failure vs System Failure
Business failure:
Requested quantity exceeds available Inventory.
The application is behaving correctly.
System failure:
PostgreSQL is unavailable.
or:
Unexpected programming failure.
The distinction affects:
HTTP mapping
logging
metrics
retry behaviour
incident investigation
Payment Needs a Clear Integration Boundary
Without isolation, we might end up with:
PayOrderUseCase
↓
provider URL building
HTTP details
provider JSON
provider status codes
credentials
all mixed together.
A cleaner design is:
PayOrderUseCase
↓
PaymentService
↓
Provider-specific HTTP integration
The UseCase understands:
request Payment
interpret application-level outcome
The external PaymentService understands:
provider URL
HTTP
provider request/response format
provider-specific errors
Do Not Build a Generic Payment Framework
We are not creating:
Stripe adapter
Adyen adapter
PayPal adapter
generic provider plugin framework
without a requirement.
We currently have one provider.
The boundary exists because external integration concerns differ from Order business logic.
Identity Boundary
The identity capability authenticates users.
Our backend receives information such as:
stable authenticated user identifier
roles / permissions
The Domain should not depend on:
JWT parsing
token structure
identity-provider implementation details
For Customer workflows:
authentication
↓
CustomerId
↓
UseCase
For Administrator operations:
authentication
↓
role / permission
↓
authorization boundary
Authorization Is More Than an Annotation
Suppose a Customer requests an Order that belongs to another Customer.
Security must block access.
But ownership also matters in application access.
A useful pattern is to scope lookup using:
orderId
+
CustomerId
rather than:
load arbitrary Order
then remember to compare ownership
Authorization is not merely a controller annotation concern.
Read Patterns Matter
The system needs to:
browse available Products
and:
view Customer Order history
These are list operations.
The design should not assume:
load every row
We already know retrieval must be bounded.
Pagination will therefore influence API and persistence design.
Write Patterns Matter Too
Important writes include:
Create Product
Update Product
Set Inventory quantity
Create Order
Cancel Order
Apply successful Payment
Some are simple.
Some are multi-state.
Some cross external boundaries.
For example:
Update Product name
is simple.
Create Order
+
consume Inventory
is transactional.
Pay Order
+
call external provider
is cross-system.
Not all writes should be designed the same way.
Think in Failure Points
For every workflow, ask:
Where can this fail?
Order creation:
invalid request
Product missing
Product inactive
duplicate Product
invalid quantity
insufficient Inventory
database failure
Cancellation:
Order missing
wrong Customer
Order already CANCELLED
Order already PAID
Inventory integrity problem
database failure
Payment:
Order missing
wrong Customer
Order not payable
provider rejection
provider timeout
provider success with local failure
database failure
Failure analysis is part of technical design.
Do Not Prematurely Choose Failure Policies
Identifying:
Payment provider timeout
does not mean we should immediately decide:
retry exactly three times
First identify the failure mode.
Later determine whether retry is:
safe
unsafe
idempotent
ambiguous
Known risk and implementation policy are different things.
Explicit Technical-Design Questions
A useful working list is:
1. What are our core Domain concepts?
2. Which responsibilities belong to each concept?
3. What are valid Order states and transitions?
4. What data must be persisted?
5. Which data must be historical snapshots?
6. Which operations require transactions?
7. How do we protect Inventory under concurrency?
8. How do UseCases access persistence?
9. How do we isolate Payment provider details?
10. How does authenticated identity enter UseCases?
11. How are business failures represented internally?
12. Which API resources expose the UseCases?
13. Which logical boundaries should remain independent?
14. Which decisions deserve ADRs?
15. Which risks belong in the RFC?
The rest of Module 2 will answer these progressively.
Technical Design Exists at Different Levels
System Level
One deployable backend application
PostgreSQL
Existing identity capability
External Payment provider
Application Level
Product
Inventory
Order
Payment
Security / Identity Context
Workflow Level
How Create Order works
How Cancel Order works
How Pay Order works
Implementation Level
Specific Java classes
Spring annotations
JPA mappings
method signatures
Good design moves from high level to low level intentionally.
Do Not Jump to Method Signatures Too Early
An early signature like:
public OrderResponse createOrder(
String customerId,
List<OrderItemRequest> items
) {
...
}
looks concrete.
But it may hide unresolved questions:
What is CustomerId?
What does Order own?
What errors can occur?
Where does the transaction live?
How is Inventory concurrency handled?
Does HTTP representation belong
inside the UseCase?
Method signatures come after responsibilities are understood.
Design Should Be Reviewable Before Code
If two engineers disagree about Inventory concurrency strategy, it is cheaper to compare:
Design option A
vs
Design option B
before hundreds of lines of code exist.
Early design review reduces expensive rewrites.
Design Is Communication
Technical design helps:
backend engineers
reviewers
future maintainers
technical stakeholders
understand:
what we propose
why we propose it
which risks exist
which alternatives were rejected
A good design makes important decisions explainable.
Design Effort Should Match Risk
A tiny endpoint may need only:
ticket + implementation note
Our Order Management Backend has:
multiple Domain concepts
transactions
Inventory concurrency
authorization
external Payment integration
That justifies a larger design artifact.
This is why we will write an RFC.
RFC Comes After Initial Reasoning
We should not begin by filling an RFC template mechanically.
First understand:
Domain
responsibilities
architecture
trade-offs
risks
Then the RFC captures the proposal.
That is why Module 2 proceeds through:
From Requirements to Technical Design
Identifying Domain Entities
Defining System Responsibilities
Designing the Initial Architecture
Writing Our First RFC
Architecture Decision Records
Reviewing and Revising a Technical Design
Design Alternatives Matter
Consider Product and Inventory.
Option A
Product contains available quantity.
Option B
Inventory is a separate concept
identified by ProductId.
Both could work.
We evaluate them using:
responsibility
business meaning
change patterns
current requirements
complexity
not:
Which option looks more enterprise?
Our eventual direction keeps Inventory separate because stock lifecycle and Product catalog lifecycle are different concerns.
Alternatives Should Be Real
A weak RFC might list:
PostgreSQL
MongoDB
Cassandra
DynamoDB
Redis
as alternatives even though PostgreSQL is already an engineering constraint.
That is fake decision-making.
Only discuss realistic alternatives for actual open decisions.
Hard Constraints Should Not Be Re-Litigated
We already know:
Java
Spring Boot
Gradle
PostgreSQL
REST / JSON
These are established context.
We should not waste design time asking:
Should we rewrite this in Go?
or:
Should we use MongoDB?
unless the requirements reveal a genuine incompatibility.
They currently do not.
State Non-Goals Explicitly
Our v1 non-goals include:
Microservices
Kafka
Redis
Multiple warehouses
Shipping
Discounts
Coupons
Refunds
Event Sourcing
CQRS
Multiple Payment providers
Identity-platform implementation
These are not bad technologies or features.
They are simply outside the current problem.
Non-Goals Prevent Review Drift
Without non-goals, a reviewer might ask:
How will this design support 50 warehouses?
The current answer is:
It does not need to.
Multiple warehouses are outside v1 scope.
That keeps design review focused.
Technical Design Is About Trade-Offs
Consider separate Inventory.
Benefits:
clear responsibility
independent Inventory changes
clean Product catalog model
ordering logic easier to reason about
Cost:
additional model
additional persistence relationship
cross-capability query for Product browsing
A good design explains why the benefit is worth the cost.
Simplicity Is a Design Property
We want:
minimum necessary complexity
That does not mean:
minimum number of files
and does not mean:
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
The system will evolve later.
But we should not design for unknown future features.
Instead, choose boundaries that are useful now and naturally make future change manageable.
For example:
Payment provider details are isolated
because external integration already justifies that.
We do not need five providers to justify the boundary.
Likewise:
Product current price
and:
OrderItem historical price
are separate because the current requirement already demands it.
YAGNI Does Not Mean “Don't Design”
YAGNI means:
You Aren't Gonna Need It
It warns against speculative functionality.
It does not mean:
Ignore transactions.
Ignore failures.
Ignore concurrency.
Ignore responsibility boundaries.
Those concerns are necessary for current correctness.
Overengineering vs Necessary Engineering
Overengineering:
Add Kafka because commerce systems
often use Kafka.
Necessary engineering:
Ensure Order creation cannot leave
Inventory consumed without an Order.
Overengineering:
Build a generic Payment-provider
plugin framework.
Necessary engineering:
Keep provider HTTP details outside
Order business logic.
This distinction should guide the project.
Design Decisions Should Trace Back to Requirements
Decision:
OrderItem stores unitPriceCents.
Reason:
Historical Order pricing must remain unchanged.
Decision:
Order creation uses one local transaction.
Reason:
Order creation is all-or-nothing.
Decision:
Order access is scoped by CustomerId.
Reason:
Customers may access only their own Orders.
Decision:
Inventory is separate from Product.
Reason:
Product catalog state and Inventory state
have different responsibilities and lifecycles.
Traceability makes design easier to review.
Initial Technical Risk Register
Risk 1 — Inventory Concurrency
Concurrent Order requests may attempt
to consume the same remaining Inventory.
The design must protect Inventory correctness.
Risk 2 — Payment Partial Failure
External Payment may succeed
while local Order state update fails.
A local database transaction cannot solve this alone.
Risk 3 — Authorization Mistakes
Incorrect ownership enforcement could expose
one Customer's Orders to another Customer.
Security design and tests must protect against this.
Risk 4 — Historical Data Drift
Changing current Product information
must not rewrite historical Order meaning.
Purchase-time data must remain stable.
Risk 5 — Scope Expansion
Commerce requirements can easily expand into
shipping, refunds, discounts, warehouses,
and unrelated capabilities.
The design must stay inside v1 scope.
Risk Is Not the Same as an Existing Bug
For example:
Payment partial failure
is a known risk because we cross an external boundary.
It does not mean the application already has a Payment bug.
Risk tells us where design attention is needed.
Architecture Decisions vs Implementation Details
Architecture decision:
Use one deployable modular application.
Implementation detail:
Name a package io.tarkev.orders.
Architecture decision:
Payment integration is isolated
behind PaymentService.
Implementation detail:
Name the provider client class
PaymentProviderClient.
ADRs should capture meaningful decisions, not every naming choice.
Design Should Leave Room for Implementation
Too vague:
The system will be clean,
scalable, and enterprise-ready.
Too detailed too early:
Every private method
inside CreateOrderUseCase.
Good technical design describes:
responsibilities
flows
boundaries
state transitions
data ownership
transaction requirements
failure modes
risks
major decisions
Implementation fills in the code-level details.
Our Initial Design Direction
Without finalizing later lessons, we can already state the direction.
We will build:
one Spring Boot backend application
with logical areas around:
Product
Inventory
Order
Payment
Security / Customer Identity Context
Application-owned persistent state uses:
PostgreSQL
Internal workflows are coordinated through:
UseCases
Persistence access uses:
Repositories
External Payment communication remains behind:
PaymentService
Authenticated identity enters from the existing identity capability and becomes Customer context.
Important local multi-state operations use transactional consistency.
This is direction, not final implementation structure.
What We Still Need to Determine
Before writing the RFC, we still need to answer:
Which Domain concepts are Entities?
Which concepts are value-like?
What exactly does Order own?
What exactly does Inventory own?
How should Product and Inventory interact?
Which responsibilities belong to each capability?
How should UseCases and Repositories interact?
Where should persistence interfaces live?
What should the package/module architecture look like?
Which decisions deserve ADRs?
Those are the next lessons.
Technical Design Review Questions
Requirements
Does the design satisfy
the relevant Acceptance Criteria?
Scope
Does it avoid unrelated functionality?
Responsibilities
Is it clear who owns each behaviour?
Data
Is data ownership clear?
Integrity
Are important invariants protected?
Transactions
Are local multi-state operations
kept consistent?
Failures
Are important failure modes recognized?
Security
Are identity, ownership,
and authorization boundaries clear?
Dependencies
Are external systems isolated appropriately?
Maintainability
Can another engineer understand
how the system works?
These questions matter more than how sophisticated the diagram looks.
A Technical Design Is a Hypothesis
A useful mindset is:
Technical design is our best current hypothesis for how to satisfy known requirements.
It is not sacred.
During implementation, we may discover:
an assumption was wrong
a framework behaves differently
a query performs poorly
a boundary is awkward
a requirement was misunderstood
Then the design can change.
Good engineering makes decisions explicitly and revises them when evidence changes.
Design Should Not Change Randomly
Healthy revision:
Original design:
Inventory uses approach A.
New finding:
Approach A cannot preserve
Inventory correctness under concurrency.
Decision:
Use approach B.
Reason:
Protect the Inventory invariant.
That is evidence-driven evolution.
This is not:
I saw a new architecture pattern
and rewrote everything.
Design and Backlog Influence Each Other
The backlog already contains work such as:
Model Order and OrderItem Domain
Implement Order Creation Workflow
Technical design may reveal:
a ticket should be split
or:
a technical dependency needs
its own work item
For example, concurrency-safe Inventory consumption may deserve explicit implementation work.
The backlog becomes more precise as the design becomes more precise.
Avoid Architecture Theatre
Our project will use:
RFCs
ADRs
system diagrams
engineering tickets
only when they improve engineering thinking.
We are not creating artifacts merely to imitate a large company.
The goal remains:
build and operate
a correct backend system
not:
produce the maximum
amount of documentation
Engineering Principle
The core principle from this lesson is:
Requirements define the behaviour we owe the user; technical design defines how responsibilities, data, boundaries, transactions, and failure handling work together to provide that behaviour.
Another important principle:
Start technical design from business behaviour and system constraints—not from framework annotations, database tables, or fashionable architecture patterns.
And:
Use the minimum complexity necessary to protect the current system's known behaviour and risks.
Where We Are Now
Our journey is:
Requirements
↓
Acceptance Criteria
↓
Engineering Context
↓
Backlog
↓
Technical Design
Technical design has now begun.
But we have intentionally stayed above code-level implementation detail.
Our next step is:
Identify the Domain concepts
that make up the system.
Summary
In this lesson, we learned that:
- Requirements describe what the system must do; technical design describes how the system will provide that behaviour.
- Technical design is more than an architecture diagram.
- Design should derive from requirements, constraints, risks, and trade-offs.
- Framework-first architecture should be avoided.
- UseCases expose application responsibilities.
- Internal workflows use
UseCase;Serviceis reserved for external integrations. - Domain modelling should come before database modelling.
- Customer identity is authenticated application context, not a local Customer-management subsystem.
- Important invariants are core design inputs.
- Different rules need enforcement at different boundaries.
- Order creation and cancellation are local transactional workflows.
- External Payment is fundamentally different because local database transactions cannot roll back remote side effects.
- Logical boundaries are useful even inside one deployable application.
- Product, Inventory, Order, Payment, and Security/Identity Context have different responsibilities.
- Historical
OrderItem.unitPriceCentsand currentProduct.priceCentsrepresent different facts. - Money uses
longcents in Java andBIGINTin PostgreSQL. - The Order lifecycle is limited to
UNPAID,PAID, andCANCELLED. - APIs should expose business operations rather than mirror database tables.
- Clients should not orchestrate backend transactions.
- Persistence contracts should emerge from real UseCases.
- Database constraints can reinforce persisted invariants.
- Inventory concurrency must be considered before implementation.
- Expected business failures should remain distinct from unexpected system failures.
- Payment provider details belong behind an external integration boundary.
- Identity-provider details should not leak into Domain logic.
- Authorization includes application-level ownership, not only HTTP annotations.
- Technical design should recognize read patterns, write patterns, and failure points.
- Non-goals prevent design from expanding into unrelated scope.
- Alternatives should be discussed only when a real decision exists.
- YAGNI discourages speculative functionality; it does not excuse ignoring known correctness problems.
- Design decisions should be traceable to requirements.
- Technical design can evolve when evidence changes.
- Technical design and backlog refinement inform each other.
- Engineering artifacts should improve decisions rather than create architecture theatre.
Next lesson:
Identifying Domain Entities
There, we will analyze Product, Inventory, Order, OrderItem, CustomerId, and the Payment boundary to determine which concepts have identity and lifecycle, which are value-like, what each concept owns, and which relationships and invariants belong in the Domain model.
