Table of contents
- Everything Works at First
- The Dependency Graph of Unstructured Code
- A Concrete Scenario — How an Order System Falls Apart
- What Is Architecture?
- Three Characteristics of Good Architecture
- Architecture Is an Investment, Not a Cost
- Architectures Covered in This Series
- A Perspective on Architecture
- Key Takeaways
Everything Works at First
When a project begins, everything moves fast. You can stuff controllers, service logic, and DB access code into a single file and it works. If there are only three features, that might even feel efficient.
The problems start from here. New requirements are added, the team grows, and deployment cadence accelerates. One day you modify the payment logic and the point accrual breaks. You try to add a notification feature, but it takes two days just to understand the order code. You want to write tests, but everything is tangled together and nothing can be verified without a DB.
This isn’t unique to any one team. It’s the universal fate of unstructured code.
The Dependency Graph of Unstructured Code
When code has no structure, what happens becomes clear in a diagram. Below is the typical dependency graph of code written without architecture.
flowchart TB
OrderController["OrderController"] --> OrderService["OrderService"]
OrderController --> PaymentService["PaymentService"]
OrderController --> UserRepository["UserRepository"]
OrderService --> PaymentService
OrderService --> UserRepository
OrderService --> NotificationService["NotificationService"]
PaymentService --> OrderService
PaymentService --> UserRepository
PaymentService --> NotificationService
NotificationService --> UserRepository
NotificationService --> OrderService
style OrderController fill:#ff6b6b,color:#fff
style OrderService fill:#ff6b6b,color:#fff
style PaymentService fill:#ff6b6b,color:#fff
style UserRepository fill:#ff6b6b,color:#fff
style NotificationService fill:#ff6b6b,color:#fff
Arrows go in every direction. PaymentService references OrderService, and OrderService also references PaymentService. Circular dependency. In this structure, touching one thing causes a chain reaction that breaks others. Even tracking who uses what becomes an arduous task.
Now let’s compare what it looks like when we build the same features with structure.
flowchart TB
subgraph presentation["Presentation"]
OC["OrderController"]
end
subgraph application["Application"]
OS["OrderService"]
PS["PaymentService"]
NS["NotificationService"]
end
subgraph domain["Domain"]
Order["Order"]
Payment["Payment"]
User["User"]
end
subgraph infrastructure["Infrastructure"]
UR["UserRepository"]
OR["OrderRepository"]
PR["PaymentRepository"]
end
OC --> OS
OS --> PS
OS --> NS
OS --> Order
PS --> Payment
NS --> User
OS --> OR
PS --> PR
NS --> UR
style presentation fill:#339af0,color:#fff
style application fill:#51cf66,color:#fff
style domain fill:#ffd43b,color:#000
style infrastructure fill:#868e96,color:#fff
The direction of arrows is consistent. Top to bottom, outside to inside. You can see at a glance which class belongs to which layer, and you can predict the blast radius of modifications. This is what architecture does.
A Concrete Scenario — How an Order System Falls Apart
Concrete examples resonate more than abstract explanations. Let’s use a simple order system as an example.
Stage 1: The Quick First Version
The code below handles everything from order creation to payment and notification in a single class.
@RestController
public class OrderController {
@Autowired private JdbcTemplate jdbc;
@Autowired private RestTemplate restTemplate;
@PostMapping("/orders")
public ResponseEntity<String> createOrder(@RequestBody OrderRequest req) {
// Check stock
int stock = jdbc.queryForObject(
"SELECT stock FROM products WHERE id = ?",
Integer.class, req.getProductId()
);
if (stock < req.getQuantity()) {
return ResponseEntity.badRequest().body("Out of stock");
}
// Deduct stock
jdbc.update(
"UPDATE products SET stock = stock - ? WHERE id = ?",
req.getQuantity(), req.getProductId()
);
// Save order
jdbc.update(
"INSERT INTO orders (product_id, quantity, status) VALUES (?, ?, 'CREATED')",
req.getProductId(), req.getQuantity()
);
// Call payment API
restTemplate.postForEntity(
"https://payment.example.com/pay",
new PaymentRequest(req.getProductId(), req.getAmount()),
String.class
);
// Send notification
restTemplate.postForEntity(
"https://notification.example.com/send",
new NotificationRequest(req.getUserId(), "Order completed"),
String.class
);
return ResponseEntity.ok("Order successful");
}
}
It works. If there’s only one feature, this structure is sufficient. But then requirements start piling up.
Stage 2: Requirements Multiply
- Add coupon discount feature
- Restore stock on payment failure
- Add point accrual
- Add order history query API
- Add admin order cancellation
With each requirement, code accumulates in the createOrder method. A single method exceeds 200 lines, with nested if-else statements. When a new developer joins, it takes a full day just to understand what this code does.
Stage 3: Modifications Become Frightening
It’s time to modify the point accrual logic. But the point calculation depends on the payment amount, the payment amount depends on the post-coupon amount, and the coupon application is wedged in the middle of the order creation flow. To change one line, you need to understand all 200 lines, and there’s no guarantee you haven’t touched another feature.
At this point, someone says “let’s refactor.” But there are no tests. To write tests, you need a DB and external APIs. Because the code is directly coupled to infrastructure. The vicious cycle has begun.
What Is Architecture?
The term “software architecture” is used in various contexts. Microservice architecture, event-driven architecture, cloud-native architecture. The terminology alone sounds grand, but the essence is simple.
Architecture is about the important stuff. Whatever that is. — Ralph Johnson
Architecture is a set of structural decisions. How will you divide the code? How will those units communicate? In which direction will dependencies flow? How far will you allow changes to propagate? Architecture is about answering these questions in advance.
An important point: architecture deals with decisions that are hard to change later. Renaming a variable isn’t architecture. Spreading code that directly references the database across every service is an architectural decision. The latter can take weeks to undo.
Martin Fowler has described architecture as the fundamental organizational structure of a system — its components, the relationships between those components, and the implementation of principles that guide design and evolution. The key words are relationships and principles. Architecture isn’t determined by which framework you use, but by which principles govern the relationships between code.
Three Characteristics of Good Architecture
There’s no single correct architecture. But good architectures share common characteristics.
Ease of Change
The reason software exists is change. Requirements will inevitably change, and the software must be able to accommodate those changes to have value. In a good architecture, when you change the payment method, you only modify payment-related code. The order logic and notification logic don’t need to be touched.
Borrowing Robert C. Martin’s expression, software has two kinds of value: behavioral value (currently working correctly) and structural value (being able to accommodate future changes). Structural value is more important. A program that works but can’t be changed becomes useless the moment requirements shift. A program that doesn’t work but is easy to change can be fixed.
Testability
If tests are hard to write, that’s a signal of structural problems. If you need to spin up a DB, call external APIs, and start a web server just to verify business logic — it’s because the logic is coupled to infrastructure.
In a good architecture, you can pull out just the business rules and run unit tests. Here’s a simple example showing the difference.
// Poor structure — testing requires a DB
public class OrderService {
@Autowired private JdbcTemplate jdbc;
public boolean canOrder(Long productId, int quantity) {
int stock = jdbc.queryForObject(
"SELECT stock FROM products WHERE id = ?",
Integer.class, productId
);
return stock >= quantity;
}
}
// Good structure — domain logic is separated from infrastructure
public class Order {
private final int stock;
private final int quantity;
public Order(int stock, int quantity) {
this.stock = stock;
this.quantity = quantity;
}
public boolean canFulfill() {
return stock >= quantity;
}
}
The Order class below can be tested with just new Order(10, 3).canFulfill(). No DB, no Spring Context needed.
Understandability
Reading code takes far more time than writing it. When a new team member joins, you should be able to guide them: “This is the project structure, and for this feature, look here.” Architecture serves as a map of the code.
If you can tell from the package structure alone what domains the project handles, where the business logic lives, and where the infrastructure code sits, that’s good architecture.
com.example.shop/
├── order/
│ ├── domain/ ← Core business logic
│ ├── application/ ← Use case orchestration
│ ├── adapter/ ← External connections (DB, API)
│ └── port/ ← Boundary interfaces
├── payment/
│ ├── domain/
│ ├── application/
│ └── ...
└── notification/
└── ...
The directory structure itself is the declaration of architecture. Without opening any code, you can see that the domains of order, payment, and notification are separated, and each follows the same internal structure.
Architecture Is an Investment, Not a Cost
“Let’s build one more feature instead of spending time on architecture design.” This is something you hear often in practice. In the early stages of a project, this logic seems persuasive. When there are few features, you can move fast without structure.
But as the codebase grows, the dynamics change. In unstructured code, the cost of adding features increases steeply over time. A feature that took a day at first starts taking a week, and eventually you hear “this feature is impossible to implement with the current structure.”
In contrast, code with architectural investment takes a bit more time initially, but the incremental cost of adding features remains gentle even as they grow. This is the nature of investment: you pay costs up front and reap interest later.
Of course, over-engineering should be avoided too. Applying hexagonal architecture to a project with only three features might be wasteful. What matters is choosing an appropriate level of structure that matches the project’s scale and growth potential. The real question isn’t “to structure or not to structure” — it’s “how much to structure.”
Architectures Covered in This Series
This series spans 6 parts, examining commonly encountered software architectures one by one.
| Part | Title | Key Question |
|---|---|---|
| Part 1 | Why Architecture Matters | Why does unstructured code fall apart? |
| Part 2 | Layered Architecture | The most familiar structure — its strengths and limitations? |
| Part 3 | Hexagonal Architecture | What changes when you separate inside from outside? |
| Part 4 | Clean Architecture and Onion | Commonalities and differences among concentric structures? |
| Part 5 | CQRS and Event-Driven | What possibilities open up when you separate reads and writes? |
| Part 6 | Modular Monolith | What can you try before microservices? |
Each part can be read independently, but reading in order provides a natural progression of architectural evolution. The limitations of layered give rise to hexagonal, hexagonal principles extend into clean architecture, and when structures within a single service scale to the system level, CQRS and event-driven emerge. And seeking the best possible outcome within a monolith before going to microservices leads to the modular monolith.
A Perspective on Architecture
There’s a caution when studying architecture. Treating a specific architecture as “the answer” is dangerous. Thinking that hexagonal is always better than layered, or that microservices are superior to monoliths, leads to poor outcomes in practice.
Architecture is a product of trade-offs. Layered is simple with low learning costs but makes it easy for domains to become coupled to infrastructure. Hexagonal protects domains but increases the number of files and interfaces. CQRS maximizes read performance but raises system complexity.
The question “What is the most important quality attribute for this project?” is the starting point for architecture selection. Is the rate of change high? Is performance critical? What’s the team size? How complex is the domain? Ignoring context and just following “current trends” makes the project harder instead.
This series aims to discuss the strengths and weaknesses of each architecture in a balanced way. The goal is for readers to develop the judgment to choose the right structure for their own projects.
Key Takeaways
- Unstructured code is fast at first, but the cost of change increases exponentially over time
- Architecture is a set of structural decisions about the relationships and dependency directions between code
- Common characteristics of good architecture are ease of change, testability, and understandability
- Architecture is an investment where you pay initial costs and reap long-term benefits
- There is no correct architecture. The appropriate level of structure for the project’s context is the best choice
In the next part, we’ll cover the most familiar and widely used structure: layered architecture. That’s the Controller -> Service -> Repository structure from Spring MVC. We’ll acknowledge this structure’s strengths while examining where its limitations emerge.
-> Part 2: Layered Architecture — The Most Familiar Structure and Its Limits




Loading comments...