System Design for Small Teams: Draw Boundaries Before Abstractions
Small teams often over-engineer by adding abstractions too early. This article proposes a decision framework based on boundary identification and dependency management to help decide when to abstract and when to keep it simple.
System Design for Small Teams: Draw Boundaries Before Abstractions
Last week a friend told me about their AI writing tool. They started with one LLM (GPT-4), then wanted to support Claude and a local model. So the team designed a "universal model adapter" — factory pattern, strategy pattern, interface definitions, parameter normalization... The code took three weeks. In the end, the three models differed in only two places: request format and response parsing. They spent three weeks on an abstraction they didn't need.
This is not an isolated case. The most common mistake small teams make in system design is not under-engineering but over-abstracting too early. We fear "what if we need to extend later" and introduce design patterns, layers, and middleware prematurely. The code becomes heavier, every change touches multiple files, and onboarding gets slower.
Why small teams over-abstract more
Three reasons:
- Experience trap — Someone from a big company brings complex patterns (microservices, event-driven, DDD) and applies them directly to a small project without context.
- Fear of the future — The boss says "we'll support ten models", so you design for ten, but in reality you only ever use two within a year.
- Engineer's pride — Code that isn't "elegant" feels wrong. You'd rather spend extra time to make the class diagram look perfect.
The cost is real: increased cognitive load, longer debugging paths, higher change cost. With a small team, one abstraction layer can turn a half-day task into two days.
My decision framework: Draw boundaries first, then relationships
After building several products, I developed a simple method called "boundary-relationship mapping". The core idea: First determine what truly needs to change independently, then decide how they connect.
Steps:
1. List all change candidates
Write down things that might change:
- Request format for different AI models
- Different databases (MySQL vs PostgreSQL)
- Different payment channels (WeChat, Alipay)
- Different UI themes
Only list changes you have experienced or are certain to encounter within the near future. Don't include "what if someday..." fantasies.
2. Score each candidate with two dimensions:
- Probability: Likelihood of change within the next 6 months. Scale 0-10.
- Impact: How many files/modules would need to change if it happens? Scale 0-10.
Example: AI model request format → Probability 8 (already planning to add a second model), Impact 5 (involves request sending, response parsing, error handling). Database switch → Probability 1 (no plans), Impact 8.
3. Decide whether to abstract:
- If probability low (<4), hardcode now, refactor when change actually comes.
- If probability high and impact large (>5), worth building an abstraction layer.
- If probability high but impact small (e.g., only one function changes), use simple encapsulation — no factory pattern needed.
Back to the AI model example: Probability 8 but Impact 5 (other logic is shared). A simple configuration file and a switch-case (or a function map) is enough. No adapter pattern required.
4. Draw boundaries: Identify which code changes together
Use the concept of bounded contexts (without full DDD). Mark areas where code tends to change together:
- Model invocation layer: request construction, response parsing, retry logic
- Business logic layer: prompt assembly, result processing, user interaction
- Data layer: persistence, caching
Principle: High cohesion inside, low coupling outside. If changes in the model invocation layer don't affect business logic, the boundary is valid.
5. Draw relationships: Define dependency direction between boundaries
Another common mistake is chaotic dependency direction. For example, business logic directly depends on concrete model invocation classes, so switching models requires changing business logic. The correct approach: Business logic depends only on a simple interface of the model invocation layer, not concrete classes. But this interface should only expose methods that business logic actually needs, not every possible model feature.
Example:
# Business logic needs:
def generate_text(prompt: str, model_config: dict) -> str:
pass
Not:
# Over-engineered
class ModelInterface:
def generate(self, prompt: str, temperature: float, max_tokens: int, stop_sequences: list, ...):
pass
def stream_generate(self, ...):
pass
def count_tokens(self, ...):
pass
Abstract only what business actually needs. Ignore what might be needed later.
A practical checklist
Before each system design decision, run through:
- Will this change actually happen within the next 6 months? (If no, don't abstract yet)
- If we don't abstract, is the future refactoring cost acceptable? (If yes, don't abstract yet)
- Does the abstraction reduce total lines of code? (If it increases, reconsider)
- Does the abstraction introduce new dependencies or third-party libraries? (If yes, weigh the cost)
- Can everyone on the team understand this abstraction? (If only one person does, it's risky)
- Does the abstraction make testing easier? (If harder, drop it)
When it might fail
This framework isn't foolproof. Over-abstracting early can sometimes be beneficial:
- The team is experienced and can accurately predict future changes, saving refactoring cost later.
- The project needs a quick demo supporting multiple models, and early abstraction reduces last-minute emergency fixes.
- Designing the abstraction is itself a learning process that helps the team understand the problem domain.
But for most small teams, especially when market direction is uncertain, keeping it simple is the safest bet. When I was building AI writing products at Chengdu Past Time, we only supported one model initially. When users asked for more, we made the change — and the effort was much smaller than expected.
Summary
System design is not about stacking design patterns. It's about managing uncertainty. Small teams have limited resources and should focus on core business logic, not on paving the way for changes that may never happen. Draw boundaries first to understand what changes together, then define clean dependency directions, and finally introduce the minimal abstraction to solve the real problem.
Next time you design a new feature, ask yourself: "If I don't abstract now and the change comes later, can I afford to refactor?" If the answer is yes, then don't abstract yet.
PaxLee