Lesson 2 of 17 · Setting the Stage
It All Depends on the Context You Are In
Simple in one context, complex in another
The same piece of code can be the right answer today and the wrong answer two years later, without a single line of it changing. The code didn't change. The situation around it did.
Building software products and writing code depends heavily on the situation you are in.
A few product contexts I can think of:
Brand website development
Package development
Small bootstrapped startup development
Well-funded startup development
Scale-up development
Enterprise development
All of these have different sets of problems and challenges that people have to deal with, and almost always the situation is unique. Someone who builds a small bootstrapped business with 2-3 developers won't be at the same scale, code-wise, as someone on a team of 20+ people building a scale-up. And neither of them will be at the same scale as an enterprise with hundreds of software engineers spread across multiple teams.
The question that breaks every dogmatic answer
People usually ask questions like "should I use repositories?", "do I need DDD?", "is service location bad?", "should controllers be thin?". I want to give a clean yes or no, because that's what they're asking for. I can't, because the honest answer always starts with the same two words: "it depends".
It depends on:
how big your team is
how long the product has been alive
how often the rules change
whether you're working alone on a side project, or one of fifty engineers maintaining a billing system that handles money for hospitals.
"It depends" sounds like a cop-out but it isn't. It's the most honest thing you can say about software, and learning to ask "depends on what, exactly?" is the most useful skill in this course.
Most arguments about architecture are really arguments where two people are answering the same question from inside two different contexts. Both of them are right, they're just right about different situations.
What I actually mean by "context"
In this course, context is the combination of a few things:
Team size. How many people are touching the codebase every day.
Product age. How long the product has been in production and how many features have accumulated over time.
Business complexity. How many rules, edge cases and exceptions the code has to handle.
Rate of change. How often the business rules, features and requirements change.
Team turnover. How often new developers join and old ones leave, which affects how much knowledge stays in people's heads versus in the code.
When I say "the context changed", I mean that one or more of these things has shifted enough that the old way of writing code doesn't serve you anymore.
A quick example
Let's say you're in a bootstrapped startup with two developers, building a SaaS product. A customer wants to close their account, so you add a button and wire it up to something like this:
<?php
declare(strict_types=1);
namespace App\Accounts;
final class CloseAccountService
{
public function closeAccount(AccountId $accountId): void
{
$account = Account::find($accountId);
$account->close();
}
}That's it, one line of code. It works, it does what the customer expects, and you move on to the next feature. At this stage, there's no reason to make it more complicated than that.
Now fast forward three years. The team is 15 people. The product has grown. Accounts now have active subscriptions, outstanding invoices, team memberships, uploaded documents, audit trails, API credentials, support conversations, marketing preferences, and a legal obligation to erase personal data within a specific time window. Closing an account is no longer a one-line operation. It looks more like this:
<?php
declare(strict_types=1);
namespace App\Accounts;
use App\Billing\Billing;
use App\Compliance\AuditTrail;
use App\Compliance\DataErasureSchedule;
use App\Correspondence\CustomerCorrespondence;
use App\Credentials\ApiCredentials;
use App\Documents\DocumentLibrary;
use App\Integrations\DownstreamSystems;
use App\Subscriptions\Subscriptions;
use App\Support\SupportConversations;
use App\Teams\TeamMemberships;
final class CloseAccountService
{
public function __construct(
private Subscriptions $subscriptions,
private Billing $billing,
private TeamMemberships $teamMemberships,
private ApiCredentials $apiCredentials,
private SupportConversations $supportConversations,
private DocumentLibrary $documentLibrary,
private DataErasureSchedule $dataErasureSchedule,
private DownstreamSystems $downstreamSystems,
private CustomerCorrespondence $customerCorrespondence,
private AuditTrail $auditTrail,
private Accounts $accounts,
) {}
public function closeAccount(AccountId $accountId): void
{
$account = $this->accounts->withId($accountId);
$this->subscriptions->cancelAllFor($account);
$this->billing->issueFinalInvoiceFor($account);
$this->teamMemberships->transferOwnershipFor($account);
$this->apiCredentials->revokeAllFor($account);
$this->supportConversations->anonymiseFor($account);
$this->documentLibrary->releaseDocumentsOf($account);
$this->dataErasureSchedule->scheduleErasureFor($account);
$this->downstreamSystems->announceAccountClosure($account);
$this->customerCorrespondence->sendClosureConfirmationTo($account);
$this->auditTrail->recordAccountClosure($account);
$account->close();
$this->accounts->save($account);
}
}Each of those steps is its own small process, with its own failure modes. A few questions that you might ask yourself:
What happens if cancelling the subscription succeeds but releasing the documents fails?
Does the account stay in a half-closed state?
Can the customer still sign in?
Who owns the shared documents they created?
What about the team they were part of?
The original $account->close() wasn't wrong. It was perfectly fine for a two-person startup with no subscriptions, no team memberships, no regulatory obligations, and no shared data. It rotted because the situation changed and the code didn't change with it.
Another angle: the data model
The same thing happens with how you model your data. Early on, you might have a single accounts table that covers everything you need:
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255),
company_name VARCHAR(255),
plan VARCHAR(50),
created_at TIMESTAMP,
updated_at TIMESTAMP
);Eight columns, easy to query, easy to reason about, easy to back up. At this stage, putting everything on the account itself is the simplest thing that could possibly work.
Two years later, that same table has grown into something like this:
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255),
company_name VARCHAR(255),
plan VARCHAR(50),
-- Billing stuff added when payments went live
stripe_customer_id VARCHAR(255),
billing_address_line_1 VARCHAR(255),
billing_address_line_2 VARCHAR(255),
billing_country VARCHAR(2),
vat_number VARCHAR(50),
custom_discount_rate DECIMAL(5,2), -- only used by one enterprise customer
-- Preferences added for the notifications feature
notification_email_enabled BOOLEAN,
notification_sms_enabled BOOLEAN,
notification_frequency VARCHAR(20),
last_newsletter_opened_at TIMESTAMP,
-- Onboarding progress tracking added for the growth experiment
onboarding_step VARCHAR(50),
onboarding_completed_at TIMESTAMP,
trial_extended_until TIMESTAMP,
-- Feature flags we never cleaned up
beta_features_enabled BOOLEAN,
legacy_ui_enabled BOOLEAN,
-- And about 40 more columns...
created_at TIMESTAMP,
updated_at TIMESTAMP
);Half of these columns are nullable because they only apply to certain kinds of accounts. The custom_discount_rate column exists because one big customer asked for a special rule that nobody else uses. The last_newsletter_opened_at column was added because creating a proper analytics table felt like too much work at the time. Feature flag columns linger long after the features shipped. New columns keep getting squeezed in because "that's where accounts live".
Reports run slowly because the table is wide. Migrations are risky because every change locks a table that half the application depends on. Every new column has to be checked against three or four unrelated features. The same team that happily added columns in year one is now afraid to touch the table in year three.
Am I actually in that kind of situation?
If any of this is starting to feel uncomfortably familiar, it's worth stopping for a moment and checking where you actually are. Answer these honestly:
Has your product been in production for more than 2-3 years?
Are there more than 5 developers touching the same codebase?
Do you have paying customers whose workflows depend on specific business rules?
Do you frequently say "we can't change that, it'll break something else"?
Does onboarding a new developer take weeks before they can safely ship a feature?
Are there parts of the codebase that nobody on the team fully understands anymore?
Read these as a gut check on where you stand right now, not a scored test. The more of them that land as "yes," the more this course is written directly for your situation. A few "yes" answers mean you're starting to feel the shift, and this is a good time to build awareness before things get worse. If almost none of them land, you're probably still in an early-stage context and a lot of this course will feel like overkill. That's fine, save it for later. It'll make more sense when you need it.
The trap of advice from the wrong context
A lot of damage to real codebases comes from advice that is technically correct but written for a different situation than the one the reader is in.
A few common shapes this takes:
Tutorial code in a real product. Tutorial code is written to teach one concept in isolation, usually in an MVP-style setting where the goal is to get something working in the shortest possible video or blog post. A login tutorial gets you a working login in 50 lines. It is not trying to handle password resets, account lockouts, multi-factor authentication, audit logging, and five years of changing security requirements. The tutorial is fine but the trouble starts when you drop it into a codebase that will outlive the tutorial by years and treat it as if it solved your real problem.
FAANG advice in a five-person startup. A staff engineer at a company with thousands of services, a dedicated platform team, and an internal RPC framework is solving problems you don't have. Their answer to "how do we structure a service" is shaped by constraints you've never met. Copying their conclusions without their constraints gets you the cost of their architecture and none of the reason for it.
Conference talks about problems you don't have. Most conference talks are about edge cases the speaker found interesting enough to talk about. That's almost by definition the wrong baseline for "what should I do tomorrow". The talk is honest but it's just not aimed at your situation.
Consultancy patterns sold as universal. "Hexagonal architecture", "clean architecture", "DDD all the way down" are tools that pay off in specific contexts. Sold as universal answers, they show up in CRUD admin panels with twelve users and turn a one-day feature into a one-week ceremony.
Most of this advice is good, so the fix is to ask, every time, "what context was this written for, and is that my context?" When the answer is "I don't know", you don't have an answer yet. You have a hint to investigate.
The other trap: refusing to grow out of a context
The opposite mistake is just as common, and harder to spot, because it doesn't feel like a mistake. It feels like discipline.
You found a way of working that fit when the team was three people, the product was new, and a feature took a day to ship. It worked. It kept working. The team grew to fifteen, then thirty people. The product has been in production for five years. There's real money on the line, actual customers who depend on it, and regulators who care what you do, but the code still looks the way it looked when you were three people on a single Trello board.
The same instincts that kept you fast at the start now slow you down. "Just put it in the controller" was reasonable when there were six controllers. It isn't reasonable when there are six hundred. "We don't need a service layer" was true when the rules fit in your head but it isn't true when nobody on the team has held all the rules in their head for two years.
People defend the old way because it feels honest. "We're not going to over-engineer." "We don't need that enterprise stuff." "It worked for us before." All of which can be true and still be the wrong answer for the situation you're in now.
A useful question to ask yourself, every six to twelve months: "If a team like ours, building a product like ours, came to me today and asked how to structure their code, would I tell them to do what we're doing?". If the honest answer is no, you've grown out of your context and the code hasn't grown with you.
What experience teaches you
When I started my career, I didn't understand why more experienced developers kept saying things like "if you build the product this way, you'll be in trouble later". It sounded like unnecessary gatekeeping. My code worked, the tests passed, and I shipped features. What was the problem?
The problem was that I was treating the present like the whole story. Code I wrote a few days ago only had to survive until the next review. I had no model of what code feels like at year three, because I'd never been there. The senior developers were trying to describe a scenario I hadn't experienced yet.
Experience is really just pattern recognition built from watching situations change. If you work on a single product long enough, you stop seeing code as right or wrong and start seeing it as appropriate or inappropriate for the situation it lives in. The same $account->close() that felt elegant on day one starts feeling naive on day one thousand, because the situation around it grew up.
You don't need years of scars to learn this, though. That's part of what a course like this is for: borrowing other people's pattern recognition so you can spot the shift earlier than they did.
"Is this just YAGNI in reverse?"
A fair pushback to everything I've said so far is: "Aren't you just telling me to over-engineer things upfront, just in case?" That's really asking whether I'm arguing for the opposite of YAGNI, "you aren't gonna need it," the rule that says don't build for a future that might not arrive. It's a reasonable concern, and the answer is no.
Building enterprise-grade architecture on day one of a two-person startup would be wasteful. The goal is to help you recognise when your situation has shifted so you can adapt before the codebase becomes painful to work with. There's a big difference between predicting every possible future and noticing when the present has already changed. That's what the self-assessment above is for: it gives you a quick way to check whether your current circumstances actually call for this kind of thinking.
The other concern is: "What if my product never gets big enough for any of this to matter?" That's a legitimate outcome. Most products don't survive long enough to hit these problems, and if yours doesn't, none of this will apply to you, but if it does survive, you'll be glad you knew what to look for.
The context this course focuses on
The main context that I will focus on in this course is:
Startups after 3 years in production
Scale-ups (startups that have grown past the early phase and are expanding quickly)
Enterprise distributed systems
The main reason is that these products are already established in terms of features and customers, and their business model is mature enough that they have to deal with real complexity on a daily basis.
The one thing to remember
Code doesn't rot on its own. It rots because the situation around it changes and nobody adapts the code to match. The skill this course is trying to teach isn't "how to write better code". It's "how to notice when your situation has changed so you can respond before the code fights you".
Every lesson that follows builds on that idea, all of it in service of helping you see the shift early and respond to it on purpose, instead of discovering the problem once the code is already fighting you. And "responding on purpose" rarely means rewriting everything. It usually means small, deliberate moves: pulling one responsibility out of a class, introducing a boundary between two concepts, renaming something so the code matches the language the business actually uses. We'll cover many of these moves throughout the course.
Exercise
Before moving on, pick whichever one of these fits your situation better.
If you have an active project: Pick one class, controller, service or model in your codebase that feels too large or too risky to change. Answer these questions in a note or a document somewhere you can come back to later:
How many different responsibilities does it have?
When was it originally written, and what did the team and product look like back then?
Which of the context factors from the definition (team size, product age, business complexity, rate of change, team turnover) has shifted the most since then?
What's the main reason you're afraid to change it?
You don't have to fix anything, the goal is just to notice.
If you don't have an active project: Imagine you're joining a team that has been building a SaaS product for 4 years. The team grew from 3 to 18 developers. They have an AccountService class that handles registration, sign-in, password resets, profile updates, subscription billing, invoice generation, marketing preferences, and personal data exports.
Why do you think the
AccountServiceended up this way? Was it a mistake, or was it a reasonable decision that aged badly?If you were joining this team, what would you want to understand before proposing any changes?
Either way, the goal is the same: practice catching context shifts before they catch you.
Related Links
- YagniMartin Fowler on why building for a future need before it arrives usually costs more than it saves.
- Canon 3X: Explore, Expand, ExtractKent Beck on why a product's phase changes which engineering approach is right.
- Choose Boring TechnologyDan McKinley on spending a team's limited attention on well-understood tools.
- Strategic Monoliths and MicroservicesVaughn Vernon and Tomasz Jaskula on choosing between a monolith, microservices, or something in between.