Escape Over-engineering — Reducing Architecture Complexity with YAGNI, KISS, and the Rule of Three
To be honest, I was once completely obsessed with microservices myself. Even though our team consisted of only three people, as we set up Kubernetes clusters, attached service meshes, and connected services via event buses, I was convinced that "this is the right direction." I also had a vague expectation that if we built it like Netflix, we would eventually become like Netflix.
Then one morning, I went in to fix a simple bug and ended up wasting the entire morning. The cause was a logic error of just two lines, but to find it, I had to sift through logs from five services, keep three monitoring dashboards open, and wrestle with distributed tracing tools. That was the first time the question crossed my mind: "Is this complexity really necessary for us?"
Over-architecture is not created by bad developers. Rather, it occurs when passionate developers, with the good intention of preparing for the future, build up complexity that the current team and service cannot handle. And the bill for that complexity always arrives later, at the busiest time.
Once you understand these principles, you will instinctively realize how expensive the phrase "I thought I might need it later" is at the next design meeting.
Key Concepts
Complexity is a debt, not a feature
When discussing software architecture, the misconception that "complexity equals sophistication" is often subtly prevalent within teams. It is as if someone who designs a complex system appears technically superior, while a simple solution seems somewhat lacking. However, in reality, complexity is an operational cost that every team member must pay every day.
A combination of factors is at play: a structure where those who design complex architectures are valued more highly within the team, the personal motivation to add "MSA design experience" to a resume, and a vague anxiety that "it might be needed later." The organization's incentive structure itself causes complexity to be mistaken for a virtue.
Over-Architecture: An anti-pattern involving the introduction of a system design that is excessively complex compared to the current team size and requirements. Typical examples include unnecessary abstraction layers, premature separation of microservices, and the adoption of unproven, modern technology stacks.
YAGNI — We don't make things we don't need right now
YAGNI (You Aren't Gonna Need It) is a principle derived from XP (Extreme Programming), and when you first encounter it, it is easy to think, "Isn't that obvious?" However, in practice, moments when YAGNI is violated usually occur in this way.
// "나중에 다른 결제 방식도 추가될 것 같아서" 만들어둔 추상화
interface PaymentProcessor {
processPayment(amount: number, currency: string): Promise<PaymentResult>;
refund(transactionId: string, amount: number): Promise<RefundResult>;
getTransactionHistory(userId: string, dateRange: DateRange): Promise<Transaction[]>;
}
class StripeProcessor implements PaymentProcessor { /* ... */ }
class PaypalProcessor implements PaymentProcessor { /* 아직 요구사항 없음 */ }
class TossProcessor implements PaymentProcessor { /* 아직 요구사항 없음 */ }The problem with this code is not the existence of PaypalProcessor or TossProcessor itself. The problem lies in the cognitive burden of designing interfaces, creating implementations, and having the entire team code according to that abstraction for "requirements that do not yet exist." In fact, even after a year, most teams still use only Stripe.
If you apply YAGNI, it becomes like this.
// 지금 필요한 것만: Stripe로 결제 처리
async function processStripePayment(
amount: number,
currency: string,
customerId: string
): Promise<{ transactionId: string; status: 'success' | 'failed' }> {
const charge = await stripe.charges.create({
amount,
currency,
customer: customerId,
});
return { transactionId: charge.id, status: 'success' };
}What if you need Toss later? You can refactor it then. If your tests are well-established, you are not afraid of changes.
Important Note: YAGNI is effective only when "testing and CI/CD are sufficiently in place." If you rely solely on YAGNI without confidence in refactoring, change costs may skyrocket later on.
KISS — Complexity must be explicitly justified
KISS (Keep It Simple, Stupid) is not simply about "writing short code." It is a mindset that if you are to introduce complexity, there must be a clear value commensurate with it. Personally, I use the standard that "the value brought by complexity must exceed the cost by more than three times," but this is not an official definition; it is a rule of thumb I established for myself in practice.
It is also helpful to ask yourself these questions when making decisions.
| Question | Judgment Criteria |
|---|---|
| Can you explain this abstraction on a whiteboard in 60 seconds? | If impossible, consider simplification |
| Can a new team member understand this code within two days? | If impossible, it is excessive complexity |
| Is the business risk that this complexity can eliminate clear? | If unclear, apply YAGNI |
| Can the current team handle the operating costs (monitoring, incident response)? | If not, adjust the scale |
Note: KISS does not mean "code without design." Clean code, testing, and appropriate design patterns should be kept separate from KISS. "Simplicity" should not mean "lack of structure."
Rule of Three — Practical Criteria for Determining the Timing of Abstraction
"Code duplication is bad" is one of the first rules every developer learns. However, if taken to extremes, it leads to "abstracting immediately after seeing duplication just twice," which actually becomes a problem. The Rule of Three introduced in Martin Fowler's Refactoring is simple: defer abstraction until the same pattern is repeated three times.
# 첫 번째 발생 — 그냥 씁니다
def send_welcome_email(user_email: str, user_name: str) -> None:
subject = f"환영합니다, {user_name}님"
body = "가입을 축하드립니다!"
email_client.send(to=user_email, subject=subject, body=body)
# 두 번째 발생 — 비슷하지만, 아직 추상화하지 않습니다
def send_password_reset_email(user_email: str, reset_token: str) -> None:
subject = "비밀번호 재설정 안내"
body = f"다음 링크를 클릭하세요: /reset?token={reset_token}"
email_client.send(to=user_email, subject=subject, body=body)
# 세 번째 발생 — 패턴이 보이고, 숨어있던 엣지 케이스도 드러납니다
def send_order_confirmation_email(
user_email: str,
order_id: str,
locale: str = "ko" # 다국어 지원 필요성이 여기서 드러남
) -> None:
subject = f"주문 #{order_id} 확인"
body = "주문이 접수되었습니다."
email_client.send(to=user_email, subject=subject, body=body, locale=locale)
# 세 번의 반복이 공통 파라미터와 엣지 케이스를 명확히 드러냈습니다
def send_email(to: str, subject: str, body: str, locale: str = "ko") -> None:
email_client.send(to=to, subject=subject, body=body, locale=locale)If you had abstracted immediately in the second case, you would have missed the locale parameter. In practice, it is common to find that it becomes clear what the actual interface needed is only after looking at the third iteration.
Important Note: The Rule of Three does not mean "copy three times unconditionally." If the pattern is clearly visible, you may abstract it in just two attempts. The key is to use it as a criterion for judgment: "If you are still unsure about the pattern, let's wait."
Practical Application
Now that you understand the concept, let's look at two examples to see how these principles work in actual team situations.
Example 1: From Microservices to Modular Monolith
The case of Amazon Prime Video is still frequently cited. They operated their video quality monitoring service as a microservice, but infrastructure costs decreased by 90% when they switched to a single process. This is a prime example of how excessive service separation caused network I/O and operational overhead to skyrocket.
However, if you ask, "Should I just go back to a monolith?", there is a better option. It is the modular monolith.
# 잘못된 방향: 경계 없는 단순 모놀리스
src/
├── controllers/ # 모든 도메인 컨트롤러 혼재
├── services/ # 모든 서비스 혼재
├── models/ # 모든 모델 혼재
└── utils/ # 모든 유틸 혼재
# 권장 방향: 명확한 내부 경계를 가진 모듈러 모놀리스
src/
├── modules/
│ ├── users/ # 사용자 도메인
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ └── users.module.ts
│ ├── orders/ # 주문 도메인
│ │ ├── orders.controller.ts
│ │ ├── orders.service.ts
│ │ └── orders.module.ts
│ └── payments/ # 결제 도메인
│ ├── payments.controller.ts
│ ├── payments.service.ts
│ └── payments.module.ts
└── shared/ # 공유 유틸 (최소화)Modular Monolith: An architecture that has a single deployment unit but possesses clear internal domain boundaries. It combines the operational simplicity of a monolith with the structural clarity of microservices.
If you want to enforce dependency rules between modules programmatically, you can use Dependency-cruiser if you are using Node.js/TypeScript.
// .dependency-cruiser.js
module.exports = {
forbidden: [
{
name: "orders-cannot-import-payments-internals",
comment: "orders 모듈은 payments의 public API만 사용할 수 있습니다",
from: { path: "^src/modules/orders" },
to: { path: "^src/modules/payments/(?!index)" }
}
]
};In the CI pipeline, it runs like this.
npx depcruise --config .dependency-cruiser.js srcIf you add this rule to your CI, module boundary violations are automatically detected during the PR phase. When microservice separation is actually required later, it becomes much easier because the boundaries are already clear.
Example 2: Allowing Only One Innovation Point
There is a case of a healthcare startup that adopted a Service Mesh and removed it eight months later. What was impressive was not the technology, but the result. Because they spent 30% of their engineering time managing the Service Mesh, actual product development took a backseat, and eventually, after removing it, Kubernetes node costs decreased by 40%.
To prevent such situations, I propose a standard to the team: "Only attempt one new technical approach at a time in a project." New technologies entail learning costs and operational risks, and introducing multiple at once makes it difficult to identify the root cause of problems when they arise.
By utilizing ADR (Architecture Decision Record, the practice of documenting architectural decisions and their reasons), you can clearly share this judgment with the entire team.
## 컨텍스트
신규 서비스 개발. 팀 규모 5명, DevOps 경험 보통 수준.
## 이번 프로젝트의 새로운 시도 (하나만)
- [ ] 새로운 데이터베이스 (예: TiDB, PlanetScale)
## 검증된 기술로 채우는 영역
- 언어: TypeScript (팀 전원 경험 있음)
- 프레임워크: NestJS (이미 사용 중)
- 인프라: 단일 서버 → ECS (익숙한 도구)
- 모니터링: Datadog (기존 계약)
- CI/CD: GitHub Actions (기존 파이프라인)
## 결정
데이터베이스만 새로운 기술을 도입. 나머지는 기존 스택 유지.
## 이유
동시에 여러 기술을 새로 도입하면 문제 발생 시 원인 파악이 어렵다.If you save this template as Markdown in the docs/adr/ folder, you can clearly answer the question "Why did I make this choice?" later.
Pros and Cons Analysis
Advantages
| Item | Content |
|---|---|
| Development & Deployment Speed | Simple systems have lower change costs, accelerating feature development |
| Reduced Onboarding Time | Significantly reduces the time it takes for new team members to grasp the architecture |
| Reduced Operating Costs | Reducing the number of services and infrastructure reduces monitoring and incident response costs |
| Bug Reduction | Unnecessary abstraction layers increase bug entry points. Reducing layers reduces bugs. |
| Reduced Debugging Time | In a real-world scenario, debugging time for deployment errors decreased from 4.2 hours to 0.8 hours after simplifying the abstraction layer. |
Disadvantages and Precautions
| Item | Content | Response Plan |
|---|---|---|
| Refactoring Liability | Refactoring costs may increase later after implementing YAGNI | Having sufficient test coverage and CI/CD in place gives you confidence in refactoring |
| Team Size Threshold | Microservices can bring substantial benefits when the team exceeds 10 members | Gradually increase complexity according to current team size and DevOps maturity |
| Misunderstood as lack of structure | Mistaking "simplicity = lack of structure" leads to spaghetti code | Maintain clear internal boundaries like a modular monolith |
| Misconception about Skipping Design | It is easy to misunderstand YAGNI as "code without design" | Keep clean code, testing, and appropriate design patterns separate from YAGNI |
If you want to check complexity numerically, you can use tools like SonarQube or CodeScene. Cyclomatic Complexity measures the number of independent execution paths within a function, and if it exceeds 10, it is time to consider refactoring. Recently, Cognitive Complexity, which quantifies the difficulty humans perceive when reading code, has become a more preferred trend.
The Most Common Mistakes in Practice
-
Team Size and Architecture Mismatch: A situation where 3 developers operate 10 microservices. In reality, a significant number of microservice teams still deploy in bulk like monoliths, failing to reap any architectural benefits.
-
Technology Adoption Without Considering Operational Costs: It is easy to overlook the fact that the maintenance costs of tools like Service Mesh, GraphQL Federation, and Distributed Tracing are much higher than the adoption costs themselves.
-
Failure to measure the justification for introducing complexity: Abstractions introduced on the grounds that "it might be needed later" are often rarely used in practice. It is recommended to clearly define "what specific business problem does this solve?" before introducing complexity.
In Conclusion
That morning, as I sifted through the logs of five services to catch a single bug, I learned the most expensive lesson: the problem lay in complexity itself. It wasn't a matter of my skill that it took me the entire morning to find a two-line bug, but the bill of a system that had become so complex that no one could handle it.
Complexity is not a feature, but a cost that every team member must pay every day. If you cannot explicitly justify that cost, it is best to keep it simple right now.
3 Steps to Start Right Now:
-
Start by measuring complexity. Connect SonarQube or CodeScene and try generating a list of functions with a perceived complexity exceeding 15.
-
Try using an ADR when introducing the next technology. It starts by creating a
docs/adr/folder and recording the context, decisions, and reasons in Markdown. -
Enforce module boundaries programmatically. You can automatically test for boundary violations using
npx dependency-cruiser --initfor Node.js/TypeScript or Spring Module 1.4 for Spring.
Reference Materials
- Scaling up the Prime Video audio/video monitoring service and reducing costs by 90% | Amazon Prime Video Tech Blog
- Bliki: Yagni | Martin Fowler
- Refactoring: Improving the Design of Existing Code | Martin Fowler
- Stick to Boring Architecture for as Long as Possible | Addy Osmani
- Choose Boring Technology | Dan McKinley
- Avoiding Premature Software Abstractions | Better Programming
- The Fractal Trap: A Visual Model for Premature Complexity in Software Architecture | Ecliptec Mobile
- Microservices vs. Modular Monoliths in 2025: When Each Approach Wins | Java Code Geeks
- The Modular Monolith 2026 Complete Guide | DEV Community
- Stop Overengineering in 2025 | DEV Community