When I started my career, I structured projects based purely on what they looked like today. The business requirement was small. The timeline was short. I thought:
“I’ll just make it work and worry about structure later.” It worked fine… for a while. Then came the “small enhancements.” Then the integrations. Then the “quick fixes.” Within months, what started as a clean, lean project became a tangled mess of tightly coupled code. One change would ripple through the system like a domino effect. The team dreaded touching it. And future-me definitely hated past-me. That’s when I learned something I now design every project around:
- Good architecture isn’t about predicting the future.
- It’s about making change cheaper. Here’s how I approach project structure today with a focus on flexibility, clarity, and resilience.
1. Establish Clear Boundaries Early (Even If the Project Is Small)
A lot of people skip good structure early on because they think:
“We’ll figure it out when it gets bigger.” But by the time it gets bigger, it’s already too late. Even in the smallest projects, I define a base structure with clear boundaries between:
- Core - Business logic, rules, domain.
- Infrastructure - External services, APIs, databases.
- Presentation / Interface - Controllers, UI, or response layers. For example, a simple Laravel or Node.js project might look like:
/src
/core
services/
models/
usecases/
/infrastructure
db/
http/
integrations/
/presentation
controllers/
routes/
validators/
This may feel like “overhead” at the beginning, but when new features arrive, I don’t have to refactor everything, I just plug them into the right layer. Think of it like city planning: even if it’s a small village today, laying down proper roads early means it can handle more traffic tomorrow.
2. Favor Clear Contracts Over Clever Dependencies
A project doesn’t collapse because of code alone, it collapses because of uncontrolled coupling. When services and modules depend on each other loosely and clearly, change is easy. When they’re entangled, it’s a nightmare. My approach:
- Use interfaces or well-defined service contracts between layers.
- Avoid reaching deep into another module’s internals.
- Keep dependency direction clear (example: core shouldn’t import infrastructure). Example: Instead of:
// Bad - controller reaches into DB directly
const order = await OrderRepository.findById(id);
I prefer:
// Good - controller talks to service layer
const order = await OrderService.getOrderDetails(id);
This gives me the freedom to later:
- Switch DBs
- Add caching
- Introduce external APIs …without rewriting half the app.
3. Don’t Build for Tomorrow - Design to Allow Tomorrow
This is where I see many engineers fall into the trap of over-engineering. They try to predict everything the product might become, and end up building complex abstractions that nobody needs yet. My rule is simple:
“Make the next change easy, not every change imaginable.”
- I don’t create unnecessary layers “just in case.”
- I keep my abstractions thin and purpose-driven.
- I make decisions that are reversible or extendable. For example, instead of baking in a massive event system from Day 1, I might:
- Start with function calls
- Wrap them with an event bus interface
- And only implement a full pub/sub system when needed This makes it easy to adapt without paying the upfront complexity tax.
4. Keep the Mental Model Simple
A good architecture should make the project easier to navigate, not more impressive on a whiteboard. I have one personal test:
“If a new dev joins the team and needs a diagram to find where a feature starts, it’s too complex.” Practical steps I follow:
- Limit the number of top-level layers to 3–5.
- Use consistent naming and structure across services.
- Keep Read.me updated with a short explanation of how things are wired together.
- Avoid “mystery glue code” that connects everything in hidden ways. A simple, predictable structure gives your team velocity. They spend less time reading code and more time shipping meaningful changes.
5. Optimize for Change, Not Control
This one took me a while to internalize. When systems grow, your instinct might be to “lock things down” to prevent breakage. But real strength comes from embracing change with guardrails, not resisting it. Some ways I do that:
- Automated tests to give confidence during refactors
- Feature flags for rolling out changes safely
- Linting & static analysis to keep structure consistent
- Modular design so new modules don’t disturb old ones It’s not about making the system rigid, it’s about making it robust.
Final Thoughts: Architecture Is About Empathy
I used to design systems for my current self. Now I design them for:
- Future me (who won’t remember every decision),
- Teammates who’ll maintain the project,
- And the product’s evolution. The best architecture isn’t the one that predicts the future perfectly. It’s the one that makes future change easy, cheap, and safe. If you structure projects well early, your team can move fast later without fear.