Capstone 1: Requirements and Architecture
This article writes no business code at all, yet it decides whether the next three articles are "building upward" or "filling holes". We do exactly one thing: turn a vague wish — "users can place orders" — into a contract nobody has to guess at. The contract states who asks for what (user stories with acceptance criteria), which things and rules exist in this world (the domain model and state machine), where each part stands (layering and module boundaries), and what the system says to the outside world (the API list).
Six terms explained in one line each — they recur throughout:
- Requirement: the sentence someone says out loud, "I want a checkout feature" — that is not a requirement, it is a wish; only the phrasing you can write a test case against counts
- User story: the wish rewritten as "As a ⟨role⟩, I want ⟨capability⟩ so that ⟨value⟩", always followed by acceptance criteria you can verify
- Domain model: the nouns and the iron rules of this business — orders, stock, payments, plus laws like "stock can never go negative" — nothing to do with database tables yet
- Aggregate root: the single door into a group of objects that must change together. Items are modified through the order, the way an invoice line is corrected by reissuing the invoice
- Layering: lining your code up by "who stands nearest to the user": Controller first, Service in the middle, Mapper last, with dependencies pointing only forward
- Contract: a specification both frontend and backend sign, fixing what the request looks like, what the response looks like and which code an error carries. Signed means no unilateral edits
think of a restaurant. The waiter (Controller) takes the order, reads out the menu and serves the dish — never storms into the kitchen to wok anything. The head chef (Service) decides "blanch this one first, then stir-fry, and plate it with the cold dish", which is business orchestration and the transaction boundary. The storekeeper (Mapper) only fetches and returns goods against a slip and never decides how much should ship (that is a business rule). Utilities, CCTV and the time clock (infrastructure: Actuator and logging) never appear on the menu, but without them the whole place closes on day one. And when the ticket passes from waiter to chef it gets rewritten: the customer's "kung pao chicken, no spice" becomes kitchen shorthand "kung pao ×1 / no chilli / extra rice" — that gap between the customer's wording (DTO) and the kitchen's wording (entity / VO) is exactly what #44 turns into code.
an order snapshot is a used ticket from the register. Today the shop raises the kung pao from 38 to 45 and renames it "Hive Secret Chicken", but your ticket from three months ago must still read 38 and the old name, otherwise month-end reconciliation can never balance. That single rule is why the next article denormalizes order_item with the product name and unit price: it copies the menu, but what it keeps is evidence.

That map is also this article's table of contents: the first branch is the call direction (Section 6), the five after it are what the six business packages own (Section 8), and common at the bottom is the three shared building blocks. While reading, hold on to one fact: no arrow on the map points back to the left — that discipline matters more than any framework detail, and its cost shows up live in the risk list of Section 10 and the error table of Section 13.
After this article you should be able to answer three questions:
- Between "checkout should be fast" and "P99 latency under 500ms", why does only the second belong in a requirements document? What is the test?
ordermay callinventorybut not the reverse — why? If someone breaks the rule, does the trouble surface at compile time, at startup or at runtime? The error table in Section 13 gives the answer.- Where is the real line between a monolith and microservices — "how many jars you ship", or "who can scale and release independently"?
The previous 40 articles took Spring apart piece by piece — IoC, AOP, Web, data access, transactions, caching, security, async and events, monitoring. But knowledge is scattered; real development demands you assemble them into one project that runs as a whole. Starting here, four articles deliver a complete, launch-ready project: BeeOrder, the Hive Order Center.
First, the boundaries. It is an e-commerce order-backend monolith that runs the main path from "a user registers" to "a user receives goods":
| Item | Definition |
|---|---|
| Name | BeeOrder, the Hive Order Center |
| Form | e-commerce order-backend monolith |
| Six modules | user / product / inventory / order / payment / notify |
| Core features | register and login, browse products, checkout, stock deduction, payment callback (simulated), order query, auto-close on timeout |
| Tech stack | Spring Boot 3.2 + MySQL 8 + Redis 7 + MyBatis + Spring Security + JWT + Actuator + Docker Compose |
| Package root | com.beeorder |
Equally important is a not-to-do list — stating what you will not build prevents scope creep better than listing features:
- No frontend: every deliverable is a REST API, verified with curl or Postman (#46 shows an optional Docker-hosted UI, but it is not required).
- No real payments: real payment needs merchant credentials and keys; we use a "mock payment gateway" plus a callback endpoint to fully reproduce the state and idempotency of payment.
- No microservice split: a monolith first, with clean boundaries; the end of this article discusses "when to split", but this project does not.
- No big data or recommendations: operational stats stop at "daily order count and revenue".
the clearer the scope, the less the next three articles drift. This statement is the "contract" for all subsequent code.
The initial requirement is often just one sentence: "users can place orders." But how much code that means, and when it is done, nobody can say. Requirements analysis turns vague expectations into acceptance-testable user stories.
The standard form is "As a ⟨role⟩, I want ⟨capability⟩ so that ⟨value⟩", with acceptance criteria attached. BeeOrder's core story cards:
| Role | User story | Acceptance criteria (testable) |
|---|---|---|
| Visitor | register and log in so I can order | phone is unique; password stored with BCrypt; login returns a JWT valid for 2 hours |
| User | browse the product list and details to choose | paginated; delisted products do not appear |
| User | submit an order to buy products | succeeds only if stock is sufficient; on success stock is deducted and status is CREATED |
| User | pay for an order to complete the purchase | on a successful callback the status becomes PAID; repeated callbacks do not double-charge |
| User | view my orders to track progress | only my own orders; filter by status |
| System | auto-close unpaid orders to release stock | 30 minutes after creation with no payment, status becomes CLOSED and stock is restored |
| Operator | view order statistics to understand the business | daily order count and revenue |
Notice the last two rows — they are not triggered by "a user click" but are system behaviors. Such requirements are the most easily forgotten, yet they directly determine whether stock is released correctly.
acceptance criteria must be testable. "Checkout should be fast" is not a requirement; "P99 latency under 500ms" becomes a load-test case.
The table above is the finished product; the animation below plays it backwards — watch one vague wish get cornered by three questions into a testable story card:

The question most often skipped is "in which scene", and that is exactly where system behaviours like "close unpaid orders after 30 minutes" come from. A story whose acceptance criteria will not come out is a story whose questions have not been asked to the end.
With requirements clear, first find the "nouns" of the world. BeeOrder has seven core domain objects:
| Entity | Key fields | Meaning |
|---|---|---|
| User | id, phone, password, nickname | the login subject |
| Product | id, name, price, status, stock_snapshot | a sellable unit; price stored in cents |
| Inventory | product_id, available, locked | sellable and locked quantities |
| Order | id, user_id, total_amount, status | the aggregate root carrying items |
| OrderItem | id, order_id, product_id, price, quantity | the price snapshot at checkout |
| Payment | id, order_id, trade_no, amount, status | one payment attempt |
| InventoryLog | id, product_id, change, type, ref_id | an audit record of every stock change |
The relationships: a user has many orders; an order contains many items; each item points to a product; each product has one inventory record; a payment maps to an order. The order is the aggregate root — changing items must go through the order.
Key business rules must be written down, because they map directly to code branches and constraints:
- Order total = Σ(unit price × quantity), stored as
DECIMALor cents; neverdouble. - Stock can never go negative: deduction must be guarded at the database level with a conditional update (
WHERE available >= ?). - Status changes only follow the state machine; no skipping from CREATED straight to COMPLETED.
- Only one valid payment per order: repeated callbacks must be idempotent.
- Every change to money or stock leaves a ledger entry for reconciliation.
the domain model is not "a copy of the database tables". Think through the business rules and aggregate boundaries first; the schema is their consequence — do not reverse the order.
Order status is the project's most central abstraction. Once the states are muddled or the transitions loose, you get dirty data like "a cancelled order receiving a successful payment". BeeOrder has six states:
CREATED → PAID → SHIPPED → COMPLETED, plus two terminal branches, CLOSED and REFUNDED. The full transition table:
| Current | Event | Target | Side effects |
|---|---|---|---|
| CREATED | payment success callback | PAID | record payment ledger, convert locked stock to deducted, notify |
| CREATED | 30-minute timeout / user cancel | CLOSED | release locked stock, notify |
| PAID | operator ships | SHIPPED | record ship time, notify |
| SHIPPED | user confirms receipt | COMPLETED | terminal, may trigger points (notify module) |
| PAID | user refunds successfully | REFUNDED | record refund ledger, optionally restore stock |
| COMPLETED | — | — | terminal, no further transitions |
| CLOSED / REFUNDED | — | — | terminal, no further transitions |
That table is the state machine's entire statute book, but reading statutes three times beats one glance at the picture. Drawn as a single map you can tape next to your monitor:

Read it by the mnemonic "one main line, two terminals, three conditions": the main line CREATED → PAID → SHIPPED → COMPLETED only moves rightward; CLOSED and REFUNDED each enter through one designated door and never leave; and the three conditions (state matches event, target is allowed, side effects transactional) are written into validation code in the next article.
Every transition must satisfy three conditions: the current state matches the event, the target is in the allowed set, and side effects run in the same transaction or via reliable events. Turning this table into an enum plus a validation method in code is next article's job.
the worst state-machine habit is hard-coding states as strings. order.setStatus("paid") with a typo compiles fine and only fails in production when the status does not match. Use the enum OrderStatus.PAID so mistakes surface at compile time.
Choosing a stack is trade-offs, not name-dropping. BeeOrder's choices:
| Area | Choice | Alternative | Why (vs the alternative) |
|---|---|---|---|
| Framework | Spring Boot 3.2 | Spring MVC 5 / Quarkus | richest ecosystem and docs; 3.2 supports virtual threads natively, JDK 17+ |
| Database | MySQL 8 | PostgreSQL | highest team familiarity and ops material; InnoDB row locks suit stock deduction |
| Cache | Redis 7 | local Caffeine | need multi-instance shared hot cache and distributed locks, which local cache cannot do |
| Persistence | MyBatis | Spring Data JPA | stock deduction and reporting need precise SQL; MyBatis keeps SQL fully controllable |
| Security | Spring Security + JWT | Session + Redis | stateless, fits multi-instance and decoupled frontends naturally |
| Monitoring | Actuator + Prometheus | in-house instrumentation | works out of the box in a standard format; no wheel to reinvent |
| Deployment | Docker Compose | Kubernetes | Compose suffices for a monolith; Kubernetes complexity is a burden here |
Each row deserves the question "why not the other one". For example, why MyBatis over JPA: the project's two most critical queries — UPDATE inventory SET available = available - ? WHERE product_id = ? AND available >= ? (atomic deduction) and the daily aggregation for reports — are exactly "precise SQL control" scenarios, where JPA's auto-generation gets in the way.
BeeOrder uses a layered monolith with four layers, top to bottom, and a one-way request flow:

- Access layer (Controller): protocol adaptation only — validation, DTO to domain conversion, calling a service, wrapping a uniform response. No business logic here.
- Application layer (Service): business orchestration and transaction boundaries. Checkout is "validate stock -> create order -> deduct stock -> record ledger", and that orchestration belongs here.
- Domain and data layer (Mapper / Repository): MyBatis handles persistence, Redis serves cache and distributed locks, MySQL is the single source of truth.
- Infrastructure: Actuator provides observability, logging spans the whole path, and Docker Compose handles local and deployment orchestration.
Read the same picture once more, this time asking per layer, top to bottom: "did this layer reach over and grab someone else's job?" The four chips in the access layer are all protocol work (auth, validation, conversion, wrapping responses); the four in the application layer are about who orchestrates whom; the data layer only stores and fetches, it judges nothing. Finish the sweep and you will find not one line pointing back up:

Module boundaries are expressed as packages. Within one module, split by business, and inside each package by controller / service / mapper / domain / dto:
com.beeorder├── user # register, login, JWT issue and parsing├── product # product list and detail (read-heavy, cached)├── inventory # stock query, atomic deduction, inventory ledger├── order # order aggregate, state machine, checkout/query/close├── payment # payment creation, callback handling, idempotency├── notify # SMS / in-app notifications (event-driven, async)└── common # uniform response, exceptions, JWT util, constants, configBoundary discipline: cross-module calls go one way — "upper calls lower", "order calls inventory". Two-way dependencies are forbidden. order depending on inventory and product is fine; inventory depending back on order creates a cycle and must be avoided.
picture this rule as the service passage of a restaurant — front of house (Controller) passes tickets back to the kitchen (Service), the kitchen pulls stock from the storehouse (Mapper), all one way. The day someone rules that "the storekeeper gets to instruct the waiter how to greet guests", both sides wait for the other to speak first and the shop never opens. That is exactly the circular-dependency error you get at startup: two beans waiting for each other.
See what that discipline governs, one click at a time — the first four nodes are each layer's "never", and the last is the bill for breaking it:
After the last node, look back at the endpoint list in Section 7: every endpoint walks down this chain, none of them reaches the Mapper directly.
So which module actually deserves to leave the monolith? Do not guess — measure it on two axes: horizontal is how often the package changes, vertical is whether it needs to scale on its own.

Two conclusions are enough to read this chart: common, top right, ripples through the entire project when touched, so tests freeze it in place; inventory, top left, is the only package that is both write-heavy and narrow-interface, so on the day a split really happens it is the first seam. A boundary is not a service — order and product, bottom right, change weekly yet need nothing more than a one-way dependency.
when should you split into microservices? Only when a module needs independent scaling (say inventory writes far outweigh the rest), an independent release cadence, or its own database. A monolith suffices here; and because the packages draw clean boundaries, extracting inventory later touches a controlled surface — exactly the value of "monolith first, clear boundaries".
The API contract is the "contract" for frontend/backend collaboration. BeeOrder uses the prefix /api/v1. The full list (about 15 endpoints):
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /api/v1/auth/register | register | public |
| POST | /api/v1/auth/login | login, returns a JWT | public |
| GET | /api/v1/auth/me | current user info | logged in |
| GET | /api/v1/products | product list (paginated) | public |
| GET | /api/v1/products/{id} | product detail | public |
| GET | /api/v1/inventory/{productId} | query stock for a product | public |
| POST | /api/v1/orders | place an order | logged in |
| GET | /api/v1/orders | my orders (filter by status) | logged in |
| GET | /api/v1/orders/{id} | order detail | logged in |
| POST | /api/v1/orders/{id}/cancel | cancel an order | logged in |
| POST | /api/v1/payments | start payment for an order | logged in |
| POST | /api/v1/payments/callback | payment gateway async callback | public (signature verified) |
| GET | /api/v1/payments/{orderId} | query the payment record of an order | logged in |
| GET | /api/v1/notifications | my notifications | logged in |
| GET | /api/v1/admin/stats/orders | operator order stats (daily) | admin |
Conventions: all responses are wrapped as { "code": 0, "message": "ok", "data": ... }; code != 0 signals a business error. /payments/callback skips JWT but must verify the gateway signature and be idempotent — it is the only publicly exposed write endpoint and the largest attack surface.
once an API is on this list, the third article may only add to it (changes must sync the contract). So spend ten extra minutes now to pin down paths, verbs and auth.
One last structural decision before coding: multi-module Maven, or a single module with packages? Many teams immediately split into beeorder-user, beeorder-order and so on, then find that touching one API means editing three poms and every build is slow.
| Option | Pros | Cons | Fits |
|---|---|---|---|
| Multi-module | strong compile boundaries, independently packageable | complex poms, painful cross-module refactors, slower startup | large teams with genuine independent releases |
| Single module | simple, IDE-friendly, fast refactors | boundaries enforced by discipline, not the compiler | small/medium projects, monolith phase |
BeeOrder picks the single-module layout. The tree:
beeorder├── pom.xml├── docker-compose.yml├── src/main/java/com/beeorder│ ├── BeeOrderApplication.java│ ├── common│ │ ├── response/ # Result, ErrorCode, global exception handling│ │ ├── security/ # JwtUtil, JwtFilter, SecurityConfig│ │ └── config/ # MyBatis, Redis, Async config│ ├── user/ # controller / service / mapper / domain / dto│ ├── product/│ ├── inventory/│ ├── order/│ ├── payment/│ └── notify/├── src/main/resources│ ├── application.yml│ └── mapper/ # MyBatis XML (complex SQL lives here)└── src/test/java/com/beeorder- Every business package keeps the same
controller / service / mapper / domain / dtolayering, consistently across the project - Complex SQL goes in
resources/mapper/*.xml, simple queries use annotations — readable and controllable - The test tree mirrors the main tree: unit tests for the
orderpackage go intest/.../order
Section 8's conclusions plus the quadrant from Section 6 make one good matching game: the left is each module's or option's situation, the right is the disposition this article prescribes. A wrong pair explains itself on the spot:
a single module does not mean "no boundaries". We partition modules by package and require one-way cross-module dependencies, keeping complexity within a range that does not hurt development speed.
The project splits into four iterations, matching four articles, each with clear deliverables:
| Iteration | Article | Deliverable |
|---|---|---|
| 1 Design | this one (#43) | user stories, domain model, state machine, API list, layout |
| 2 Modeling and API | #44 | table DDL, entities and mappers, controller and DTO contract |
| 3 Core business | #45 | checkout, atomic stock deduction, idempotent callback, auto-close (TX and cache) |
| 4 Delivery | #46 | unit/integration tests, CI/CD, Docker Compose deployment and Actuator observability |

Use that animation as a progress bar: its six frames land on the four rows of the table above — frame 1 is this article, frame 2 is #44, frame 3 is #45, frames 4-6 all belong to #46. When a frame stalls you, look up which row you are standing in. Each frame delivers exactly one thing, and nothing moves on until the previous one is accepted — that discipline keeps four articles from ending as four piles of code that never run together:

the dependency between iterations is one-way — the implementation in (3) strictly follows the API list and state machine fixed in (1). That is why this article must nail the definitions: it is the shared source of truth for the next three.
Listing risks at kickoff beats a post-mortem after an incident. BeeOrder's four main risks:
| Risk | Impact | Response |
|---|---|---|
| Overselling stock | selling goods that do not exist, financial loss | conditional update WHERE available >= ? plus a transaction; separate pre-lock and deduct |
| Duplicate payment | charging the same order twice | idempotent callback (unique key trade_no) plus state-machine validation |
| Lost messages | missing notifications/points, user complaints | local message table + scheduled compensation + idempotent consumer (see #41) |
| Slow large-table queries | order lists get slower over time | composite index on user_id + status; cursor pagination; archive old orders if needed |
this risk list is a pilot's pre-flight checklist. The captain does not doubt his skill — he refuses to trust his memory, so every item must be confirmed by one concrete action (writing down the mitigation) instead of "I'll remember it". The second row, duplicate payment, works the same way: the same cinema ticket must never be printed twice, and what guarantees it is not "let me first check whether it was already printed" but a ticketing system that honours only the first request for that ticket number. In code that discipline is called an idempotency key, and #44 Section 8 turns it into a unique index.
Before freezing the design, run four labs that show what this architecture actually prevents. Lab 1: layers and dependency direction — it draws that restaurant service passage as real calls:
Lab 2: one request across the whole site. The architecture picture shows four boxes, but a request walks a far longer road — authentication, validation, transaction, stock deduction and messaging each live at a different stop:
Lab 3: conditional assembly, which answers "same feature, but staging and production must behave differently". Conditional annotations — the @Conditional family, the gate that decides whether a bean gets wired based on whether a class exists, a bean exists or a property is set — are the bouncers of auto-configuration:
Lab 4: your own starter, which packs all three lessons into one box. A starter — a bundle of dependencies plus auto-configuration, ready to use the moment you declare it — is literally "other people's hard-won lessons" put in a box. BeeOrder will pull in spring-boot-starter-web, mybatis-spring-boot-starter and friends, so it is worth knowing what is inside a box:
Lab 5: type those conclusions into a real container. The four labs above let you look; this console lets you type — boot the container, name the beans, then run layering, layer-skipping, the request journey and condition evaluation as one pipeline:
Once beans has listed them, recall the layer picture from Section 6: every bean's seat is decided by its package and the direction of its dependencies — far more intuitive than memorising a directory tree.
this article wrote not one line of business code, yet it determines the fate of the next three. The essence is three things: unfold "users can place orders" into user stories with acceptance criteria; nail down the order state machine and domain rules in writing; and explain the trade-offs behind module boundaries and technology choices. BeeOrder's boundaries, state machine, API list and layout are frozen. Next article we build the tables, write the mappers and fix the controller contract according to this contract. With the foundation solid, building upward is fast.
Architecture will not change how long your day is, but it decides how many files one requirement has to touch. The sandbox below folds Sections 6 and 8 into a single three-position switch; each position shows side by side whether the app boots, how many files an added column touches, and whether cross-module calls now go over the network:
Add a recipient field: 4 files (entity / DTO / VO / one ALTER TABLE)Full mvn test: 38sStartup: 2.4s# package boundaries hold only by discipline — one wrong IDEA import reaches a sibling packageRisk: someone lets inventory import order the other way, and the compiler says nothing
of the three positions only the last is irreversible — a monolith can be split, but almost nobody ever merges microservices back. So the order never changes: monolith first, boundaries drawn honestly, split along those seams only when they truly burst.
A warm-up question on how to read the technology-choice table in Section 5:
Now a combined question threading Sections 6, 7 and 10:
However tidy the design doc, day one of coding still hits walls. Copy each fragment below straight into a search box — do not paraphrase or shorten it.
| Error text (fragment) | What really happened | 30-second fix | Read more in |
|---|---|---|---|
Field orderMapper in com.beeorder.order.service.OrderService required a bean of type 'com.beeorder.order.mapper.OrderMapper' that could not be found | The package sits in the wrong place: @SpringBootApplication scans its own package and its sub-packages only. Put the mapper under com.beeorder.dao (a sibling of com.beeorder.order) or outside the startup class entirely and the container simply never sees it | First check that the startup class is the common ancestor of every business package; if the class really lives outside, name it explicitly with @MapperScan("com.beeorder.**.mapper") instead of blasting a wide @ComponentScan | Section 6 · #16 Hello Spring Boot |
APPLICATION FAILED TO START + The dependencies of some of the beans in the application context form a cycle + orderService ==> inventoryService ==> orderService | A reverse cross-layer or cross-module call closed a loop. The usual trigger is the quiz above: low-level inventory importing high-level order. Note that since Spring Boot 2.6 spring.main.allow-circular-references defaults to false, so both constructor and field injection stop here; flip it to true and only the field-injection side gets talked through by the three-level cache | Break one edge of the cycle the log draws: pull the shared logic into common or a new package so arrows run one way again. Do not treat that switch as the cure — it merely hides the architecture debt until runtime | Section 6 · #10 Circular dependency |
org.springframework.dao.DuplicateKeyException: Duplicate entry '10015-9f2c1e0a' for key 'order.uk_order_idem' | A database unique index shut out a duplicate submit — this is not a bug, it is the anti-double-order defence from the risk list in Section 10 genuinely firing | Do not let it reach the user: catch it in the service and return the first result unchanged (that is idempotency) rather than throwing a 500. Code in #44 Section 8 | #44 Idempotency · #31 Transaction internals |
Cancelling a paid order returns code=0; the refund happens but the status stays PAID | @Transactional sits at the wrong layer: it annotates a private service method, or is reached through this.xxx() self-invocation, or you moved "refund + update status" straight into the Controller calling the mapper twice. Transactions ride the proxy, and none of these give the proxy a chance, so each update commits on its own | Move @Transactional onto the public orchestration method of the application-layer service and make sure it is called from outside (the "where the tx boundary sits" position of Lab 1 in Section 10 is exactly this scene) | #31 Transaction internals · #12 Dynamic proxy |
WARN ... : Cannot enhance @Configuration bean definition 'xxxConfig' since its singleton instance has been created too early, or a @Bean unexpectedly missing | Auto-configuration and your own config race: condition evaluation happens during bean-definition registration, so @ConditionalOnBean matched against an ordinary @Component frequently reads "not there yet" at the moment of the decision | Use @ConditionalOnProperty or @ConditionalOnMissingBean for ordinary beans and reserve @ConditionalOnBean for auto-configuration classes. Run with --debug and read the condition evaluation report for the verdict | #19 Conditional beans · #18 Auto-configuration |
No qualifying bean of type 'com.beeorder.common.config.RedisProperties' available (the custom starter was imported but did nothing) | The starter trio: ① the auto-configuration class was never registered in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports; ② the properties class lacks @EnableConfigurationProperties or setters; ③ the prefix in application.yml is misspelled, so binding silently matches nothing | Walk the four steps "entry -> properties binding -> bean creation -> turning it off" one by one; Lab 4 in Section 10 turns them into switches | #20 Your own starter · #21 Config and profiles |
Error starting ApplicationContext. ... Consider defining a bean of type 'org.springframework.jdbc.core.JdbcTemplate' in your configuration | Only the mysql-connector-j driver was added without a datasource starter, so the auto-configuration condition "class present" fails and not even a pool is built | Add spring-boot-starter-jdbc (or the MyBatis starter), then check the wiring report via logging.level.org.springframework=DEBUG to see it being skipped | #27 JDBC evolution · #18 Auto-configuration |
five of those seven share one root — the container never got hold of that thing. When beginners read required a bean ... could not be found the reflex is to add an annotation; the right reflex is three questions: is it under the startup class's package tree? Is its starter on the classpath? Does the auto-configuration condition actually hold?
That cycle in the table's second row reads very differently as the original text. Here is the raw report; every fragment is clickable, and a wrong pick still tells you why it is not the one:
If you actually did the quiz from Section 12 in code, the tail of the startup log looks like this. First time round it is tempting to stare at the last sentence, while the real information sits higher up.
One line of review is enough: the only spot you actually edit is that backward import inside the inventory package; every other frame explains why it is not the one.
Three tiers: level 1 runs if you type it in; finish level 3 and you own your own architecture review checklist.
Goal: assemble the BeeOrder skeleton yourself, then deliberately break the wiring once and watch the could not be found failure happen.
Step one, a new pom.xml (Spring Boot 3.2.5 + Web + JDBC; it starts fine without MySQL):
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <groupId>com.beeorder</groupId> <artifactId>beeorder</artifactId> <version>0.0.1-SNAPSHOT</version> <name>beeorder</name> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build></project>No need to type that pom by hand — tick it out. The three defaults (Web / Validation / JDBC) are exactly the minimal skeleton level 1 needs; then look over the dependencies the next three articles will pull in — know the total first, then decide what actually goes in today:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>Step two, create the packages from Section 8 and write the startup class plus two minimal classes. The startup class must sit in com.beeorder, the common ancestor of every business package:
package com.beeorder;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublic class BeeOrderApplication { public static void main(String[] args) { SpringApplication.run(BeeOrderApplication.class, args); }}package com.beeorder.order.mapper;import org.springframework.stereotype.Repository;@Repositorypublic class OrderMapper { public String ping() { return "mapper-ok"; }}package com.beeorder.order.controller;import com.beeorder.order.mapper.OrderMapper;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController@RequestMapping("/api/v1/ping")public class PingController { private final OrderMapper orderMapper; public PingController(OrderMapper orderMapper) { // constructor injection: complete at birth this.orderMapper = orderMapper; } @GetMapping public String ping() { return orderMapper.ping(); }}Step three, start it and call it:
mvn spring-boot:runcurl http://localhost:8080/api/v1/pingExpected response — one bare line, no JSON envelope (the envelope is next article's job):
mapper-okStep four, break the wiring. Rewrite OrderMapper.java the way MyBatis projects really write it — an interface plus @Mapper — and move it from com.beeorder.order.mapper to com.beeorder.dao. Notice it is still under the startup class's subtree, so "outside the scanned package" is not the excuse this time:
package com.beeorder.dao;import org.apache.ibatis.annotations.Mapper;@Mapperpublic interface OrderMapper { String ping();}Restart, and the console stops right here:
***************************APPLICATION FAILED TO START***************************Description:Field orderMapper in com.beeorder.order.controller.PingController required a bean of type 'com.beeorder.dao.OrderMapper' that could not be found.Action:Consider defining a bean of type 'com.beeorder.dao.OrderMapper' in your configuration.Two layers of cause, the second more subtle: ① an interface has no implementation for the container to instantiate reflectively — a mapper instance is produced on demand by MyBatis' dynamic proxy, never by component scanning; ② @Mapper carries no @Component semantics, so being inside the scanned subtree changes nothing. Without mybatis-spring-boot-starter (or without telling it where to find the interfaces), the chain snaps at the wiring stage.
Step five, repair it: add @MapperScan("com.beeorder.dao") to the startup class and make sure the MyBatis starter is in pom.xml. Restart, curl again, and mapper-ok returns. The lesson you keep forever: "can the package be scanned" and "who wires this kind of object" are two different questions — ordinary classes via component scan, mapper interfaces via @MapperScan, third-party pieces via starter auto-configuration.
Acceptance checklist: ① explain why step four failed and step five succeeded; ② name the single word in that error that actually decides the outcome (scan); ③ switch the controller from constructor injection to a field @Autowired and restart — it still fails, because "does this bean exist" is answered during wiring regardless of injection style. Say that clearly and you have separated "missing bean" from "bean ordering".
Change exactly one thing per run and the conclusion flips:
- Build a reverse dependency across layers: give
OrderMapperaprivate final OrderService orderService;(constructor injection) whileOrderServiceconstructor-injectsOrderMapper. You will observe startup failing with the log drawingorderService ==> orderMapper ==> orderService. Now switch theOrderMapperside to field injection: on Boot 3 it still fails, becausespring.main.allow-circular-referenceshas defaulted tofalsesince Spring Boot 2.6; only when you set it totruedoes the field-injection side get through via the three-level cache — the app runs, but the module boundary has collapsed. - Move
@TransactionaloffOrderService's public orchestration method onto a private helper in the same class and call it throughthis.helper(). You will observe neither variant raising anything nor rolling back — transactions ride the proxy, and the proxy can only intercept "a public method called in from outside". Put the annotation back on the public method, invoked by the controller through the injected bean, and the second failing write now undoes the first. Compare with the "where the tx boundary sits" position of Lab 1 in Section 10; both should tell the same story. (Do not try to prove it withgetClass(): only beans that actually match advice get proxied, and controllers usually do not.) - Delete
spring-boot-starter-jdbcfrompom.xml. You will observe the app booting normally as long as nothing injectsJdbcTemplate— dropping a starter drops capability, it need not error; the moment something injects it, startup reportsNo qualifying bean. Cross-check the condition evaluation report from--debugand find theDataSourceAutoConfigurationline markedmatched: false.
Tip: after variant 1, reopen the second row of the table in Section 13 — both should match exactly.
Write yourself an architecture health check you can run on any future project before touching its code.
Requirements:
- A
LayerRulesTest(plain JUnit + reflection, no business class may be modified) that walks every.javafile undersrc/main/javareading itspackageandimportstatements - Enforce three rules: ① no
controllerpackage imports amapperpackage; ② no package imports a class fromorderthat in turn imports back (detecting cycles of length 2); ③commonimports no business package - On violation print a table: file, line, offending import, which rule broke, suggested extraction
- Ship an
ARCHITECTURE.mdtemplate stapling the Section 6 layer diagram, the Section 7 endpoint list and this rule table together
Acceptance checklist: ① run it on the current skeleton and rule ① immediately catches PingController importing OrderMapper; ② insert an OrderService as suggested and the test goes green; ③ fabricate an inventory -> order -> inventory cycle and the script reports it in the test phase instead of waiting for the app to fail at boot; ④ the whole exercise changes zero lines of business code.
without looking back, order BeeOrder's four layers from nearest to furthest from the user, and name the one thing each layer is never allowed to do.
what is missing from "users can place orders" that disqualifies it as a requirement? Rewrite it as a story card whose acceptance criteria include at least two lines you could turn into tests directly.
order may call inventory but not the reverse. Explain it with the restaurant analogy, then say at which stage a breach bites first — compile, startup or runtime.
why must persistence be chosen from "the two hardest SQL statements in this project" rather than "which framework feels easier"? Recite both statements.
which signals would justify extracting inventory from the monolith, and what must already be true before you attempt it?
Mantra: **requirements must be testable, status must follow the machine, arrows point only downward, boundaries live in packages, choose by the hardest job, split only when it truly bursts.**