When I joined my team, we had a codebase filled with legacy spaghetti code, outdated packages, and an overall fragile system. I was tasked with improving the infrastructure to make everything more robust - where do we even start?
We started spitballing ideas and wrote out everything we wanted to fix. The list grew... and grew... and grew. But as we stared at our giant doc, a pattern emerged: almost every improvement we wanted to make felt impossibly risky.
Refactor that gnarly authentication module? What if we break login for everyone?
Update those ancient dependencies? What if the new versions don't play nice with our custom configurations?
Update the giant catch-all API? What if something we forgot about completely breaks?
We were paralyzed by our own code. And that's when we realized something - a fragile system is an unpredictable system, and an unpredictable system is terrifying. Nobody wants to be the developer who takes down production on a Tuesday afternoon.
But flip that equation around, and you get something beautiful: predictability creates comfort.
So the million-dollar question became: How do we make this chaotic system more predictable? Writing tests!
Making SOLID Code
Now I know tests can feel somewhat controversial in the start-up world. Anyone who's been part of a larger organization can attest to how time consuming writing robust tests can be. Striving for 100% test coverage might make sense for a billion dollar company, but for a start-up where time is of the essence, it hardly makes sense. Many start-ups forgo tests all together, and honestly, I don't blame them.
But here's what we landed on: pragmatic testing. We focused our testing efforts on the mission-critical stuff - anything touching payments, user data, or core business logic. For everything else? We adopted a "test when it breaks" approach, writing tests as we encountered bugs and regressions.
The real game-changer, though, wasn't how much we tested - it was making our code testable in the first place.
Picture our codebase before the cleanup: API files that stretched on forever, with authentication, business logic, database queries, and error handling all squished together in one massive function. These files weren't just hard to read - they were impossible to test. How do you write a unit test for something that does fifteen different things?
The answer? Break it down. Make each piece do one thing really well.
If anyone is familiar with SOLID principles, you might recognize this as the Single Responsibility Principle (the "S" in SOLID).
SOLID Principles
Building blocks for maintainable, testable code
S
Single Responsibility
Each class or function should do one thing and do it well.
O
Open/Closed
Open for extension, closed for modification. Add new features without changing existing code.
L
Liskov Substitution
Objects should be replaceable with instances of their subtypes without breaking functionality.
I
Interface Segregation
Don't force classes to depend on interfaces they don't use. Keep interfaces focused and lean.
D
Dependency Inversion
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Originally designed for OOP, but these principles apply everywhere 🚀
For our APIs, we adopted the Controller-Service-Repository pattern - a clean separation of concerns that made each layer easy to understand and easy to test. For our React components, we split "smart" components (handling data and state) from "dumb" components (pure UI rendering).
The best part? When code follows the Single Responsibility Principle, testing becomes almost effortless. Instead of trying to test a monolithic function that does everything, you're testing small, focused pieces that have predictable inputs and outputs.
Controller-Service-Repository Pattern
As you might guess, a thousand line API isn't easy to read - it isn't easy to debug, and it certainly isn't easy to test. The Controller-Service-Repository pattern was a clean way to break this chaos into three focused, manageable pieces. Each layer has a specific job, and if it's not doing that job, it doesn't belong there.
Controller-Service-Repository
Clean architecture for maintainable APIs
Controller
Handles requests & responses
Service
Contains business logic
Repository
Manages database operations
The Controller
Think of the Controller as the bouncer at a club. Its job is simple: check IDs (authentication), validate the guest list (request parameters), and decide who gets through the door. That's it - just focused gatekeeping.
Our Controllers became laser-focused: validate the user's permissions, sanitize the incoming data, and hand off the real work to someone else. Clean, predictable, and easy to test.
class UserCreateView(APIView):
permission_classes = [IsAuthenticated]
def __init__(self):
self.user_service = UserService()
def post(self, request):
# 1. Validate request data
serializer = UserCreateSerializer(data=request.data)
if not serializer.is_valid():
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
# 2. Check user permissions
if not request.user.has_perm('users.add_user'):
return Response(
{'error': 'Permission denied'},
status=status.HTTP_403_FORBIDDEN
)
# 3. Hand off to service layer
try:
user = self.user_service.create_user(
serializer.validated_data,
created_by=request.user
)
# 4. Return success response
response_serializer = UserResponseSerializer(user)
return Response(
response_serializer.data,
status=status.HTTP_201_CREATED
)
except ValidationError as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
The Service
This is the API's brain - where all the unique business logic lives. Need to calculate shipping costs based on complex rules? That's Service territory. Want to transform user data before saving it? Service layer all the way.
The key insight here: Services don't talk to databases directly. They focus purely on the "what" and "how" of your business rules, staying blissfully unaware of where data comes from or goes. Notice how it uses repositories for data access and external services for things like email. This makes it incredibly testable - you can mock the repositories and test your business logic in isolation.
class UserService:
"""
Contains all user-related business logic.
Orchestrates operations between repositories and external services.
"""
def __init__(self):
self.user_repository = UserRepository()
self.email_service = EmailService()
@transaction.atomic
def create_user(self, user_data, created_by=None):
# Business Rule: Check if email already exists
if self.user_repository.email_exists(user_data['email']):
raise ValidationError('User with this email already exists')
# Business Logic: Process password
processed_data = self._prepare_user_data(user_data)
# Create user through repository
user = self.user_repository.create(processed_data)
# Business Logic: Set up user profile
self._setup_user_profile(user, created_by)
# Business Logic: Send welcome email
self._send_welcome_email(user)
return user
def _prepare_user_data(self, user_data):
# Complex business logic for data preparation
processed_data = user_data.copy()
# Hash password
processed_data['password'] = make_password(user_data['password'])
# Generate username if not provided
if not processed_data.get('username'):
processed_data['username'] = self._generate_username(
user_data['email']
)
return processed_data
def _setup_user_profile(self, user, created_by):
# Business logic: Create default profile settings
profile_data = {
'user': user,
'created_by': created_by,
'notification_preferences': self._get_default_notifications()
}
self.user_repository.create_profile(profile_data)
def _send_welcome_email(self, user):
# Business logic: Send personalized welcome email
self.email_service.send_welcome_email(
user.email,
user.first_name,
account_type=user.account_type
)
The Repository
The Repository is like having a brilliant librarian who knows exactly where every book is stored and how to get it quickly. It handles all the database conversations - the SQL queries, ORM magic, and data structure details - so other layers can focus on what they do best.
The service layer doesn't need to know about Django's query optimization tricks; it just calls clean, descriptive methods. This separation makes database changes much easier to manage and test.
class UserRepository:
"""
Handles all User model database operations.
Encapsulates complex queries and database optimizations.
"""
def create(self, user_data: dict) -> User:
# Pure database operation
return User.objects.create_user(**user_data)
def email_exists(self, email: str) -> bool:
# Efficient existence check
return User.objects.filter(email=email).exists()
def create_profile(self, profile_data: dict) -> UserProfile:
# Related model creation
return UserProfile.objects.create(**profile_data)
def get_active_users_with_profiles(self) -> List[User]:
# Complex query with prefetch for performance
return list(
User.objects
.filter(is_active=True)
.select_related('profile')
.prefetch_related(
Prefetch(
'groups',
queryset=Group.objects.only('name')
)
)
.order_by('date_joined')
)
The Payoff
Here's where it gets exciting: each layer becomes modular, reusable, and most importantly testable in isolation. When something breaks (and it will), you know exactly which floor of your code building to check.
Compare this to our old approach where everything lived in one massive function. Bug hunting used to be like searching for a needle in a haystack. Now, we can pinpoint issues in minutes instead of losing entire afternoons to debugging the issue.
Smart and Dumb Components
Just like we separated our backend concerns with the Controller-Service-Repository pattern, our frontend needed its own architectural makeover. Our React components had fallen into the same trap as our old APIs - giant, monolithic files that tried to do everything, everywhere, all at once.
The solution was beautifully simple: split components by their actual responsibilities.
Enter the "dumb" component - our new best friend. These components were blissfully ignorant about where their data came from. Hand them a user object? They'd render it beautifully. Pass them a loading state? They'd show a spinner. No questions asked, no server calls required. This meant we could throw them into a tool like StorybookJS and see exactly how they'd look across every screen size, every edge case, every possible state - all without touching a database.
// Dumb Component - Pure UI logic
const UserCard = ({ user, isLoading, onEdit }) => {
if (isLoading) return <Spinner />;
return (
<div className="user-card">
<Avatar src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
<Button onClick={() => onEdit(user.id)}>Edit</Button>
</div>
);
};
The "smart" components were the orchestrators, the ones who knew how to fetch data, handle errors, and manage all that complex async logic. They'd wrap our dumb components like a protective shell, feeding them exactly the data they needed to shine.
// Smart Component - Data and logic
const UserCardContainer = ({ userId }) => {
const { user, isLoading, error } = useUser(userId);
const handleEdit = (id) => {
// Handle edit logic, navigation, etc.
};
if (error) return <ErrorBoundary error={error} />;
return (
<UserCard
user={user}
isLoading={isLoading}
onEdit={handleEdit}
/>
);
};
Ironically, we didn't actually write that many tests - I know that sounds counterintuitive in a post about testing, but here me out. When your code is naturally testable, when responsibilities are crystal clear and dependencies are minimal, bugs have fewer places to hide. Our visual components could be tested in isolation, our data-fetching logic was cleanly separated, and suddenly our entire system felt more stable.
The real magic happened when we needed to extend features. Instead of untangling a web of mixed concerns, we could confidently modify either the visual layer or the data layer without fear of breaking the other. It was like having a well-organized toolbox instead of a junk drawer.
Sometimes the best way to write good tests isn't to write more tests - it's to write code that makes testing feel effortless.