Code you can change: inheritance, polymorphism, SOLID and taking over an existing system
A practical workshop in building and taking over code: when to use inheritance, how to design polymorphism, apply SOLID without overengineering and safely refactor an unfamiliar system.
The full article is available below. Enable JavaScript to use quizzes and simulations.
A practical architecture workshop
Code is good when the next change no longer requires courage.
We will take over an unfamiliar deployment-checking module. First, we will reconstruct its contract, distinguish inheritance from composition, use polymorphism and take a change through testing and production acceptance.
Writing code that nobody uses yet is the easy part. Adding a feature to a system that earns revenue, sends messages, processes payments and carries years of decisions made under time pressure is harder.
This material is more than a collection of definitions. We will work through a specific situation: you are taking over a module responsible for deployment checks. It has base classes, overridden methods, a large switch, several exceptions used as “normal” control flow and tests that mostly check mocks. You need to add a security check without accidentally bringing production to a halt.
1. A small change in unfamiliar code
The task says: “add a check that verifies production secrets come from a vault before deployment”. In the repository, you find a class called ReleaseCheckBasewith eight subclasses. One overrides a method and returns null, another throws NotSupportedException, and a third requires a protected field in the base class to be set.
What consumers actually rely on, rather than what was originally intended.
Database, network, files, queue, cloud, email and telemetry.
Business behaviour, or only the order of calls to mocks?
An adapter, small interface, handler or feature flag instead of a wholesale rewrite.
Decision 1 of 4
What do you do first?
You have two hours, an unfamiliar module and a production task.
I tidy up folders and names
I trace the flow and protect the existing behaviour
I rewrite the module from scratch
Choose a move and see its consequence.
2. Inheritance is a strong agreement
Inheritance creates a relationship far stronger than code sharing. A subclass inherits the lifecycle, state, assumptions and future changes of its base class. The question is therefore not “do these classes share fields?” but “can every subclass be used wherever the base type is expected, without surprises?”
public abstract class FileStore
{
public abstract Task<byte[]> Read(string key, CancellationToken ct);
public abstract Task Save(string key, byte[] data, CancellationToken ct);
}
public sealed class ReadOnlyArchiveStore : FileStore
{
public override Task<byte[]> Read(string key, CancellationToken ct)
=> archive.Read(key, ct);
public override Task Save(string key, byte[] data, CancellationToken ct)
=> throw new NotSupportedException("Archiwum jest tylko do odczytu.");
}
ReadOnlyArchiveStore is not a fully substitutable FileStorebecause part of the base type's promise no longer holds. Calling code must know the concrete type or catch an exception, making the polymorphism superficial.
Every subclass receives read, write and delete operations, even where they make no sense.
IFileReader and IFileWriter describe exactly what the consumer needs.
public interface IFileReader
{
Task<byte[]> Read(string key, CancellationToken ct);
}
public interface IFileWriter
{
Task Save(string key, byte[] data, CancellationToken ct);
}
public sealed class ArchiveStore : IFileReader { /* ... */ }
public sealed class BlobStore : IFileReader, IFileWriter { /* ... */ }
When is inheritance a good choice?
- The “is a” relationship is true in the domain, rather than merely resembling something in the code.
- The base contract is small, stable and covered by a contract test.
- Subclasses do not weaken the result or require additional preconditions.
- The base class does not expose large amounts of protected mutable state.
- Every implementation preserves the semantics of errors, cancellation and empty values.
A good use case: a stable process skeleton
Template Method can be appropriate when the sequence of steps is part of a stable contract and the extension point is small and precisely defined. You still need a shared test for all subclasses. If subclasses begin skipping steps or changing their order, composition will be clearer.
Decision 2 of 4
An implementation throws “operation not supported”. What does that usually mean?
Documenting the exception is enough
The contract is too broad
We need another base class
Check whether the relationship follows the Liskov Substitution Principle.
3. Polymorphism: one decision, many valid implementations
Polymorphism is valuable when the caller knows the contract without needing to know the concrete types. There should not be a second selection mechanism alongside it, hidden inside a switch.
public interface IReleaseCheck
{
string Code { get; }
Task<CheckResult> Evaluate(ReleaseContext context, CancellationToken ct);
}
public sealed class SecretSourceCheck : IReleaseCheck
{
public string Code => "secret_source";
public async Task<CheckResult> Evaluate(
ReleaseContext context,
CancellationToken ct)
{
var bindings = await context.Secrets.GetBindings(ct);
var invalid = bindings
.Where(x => x.Environment == "production" && !x.FromVault);
return invalid.Any()
? CheckResult.Block(Code, "Production secrets must come from a vault.")
: CheckResult.Pass(Code);
}
}
public sealed class ReleaseGate
{
private readonly IReadOnlyList<IReleaseCheck> checks;
public ReleaseGate(IEnumerable<IReleaseCheck> checks)
=> this.checks = checks.ToList();
public async Task<ReleaseDecision> Evaluate(
ReleaseContext context,
CancellationToken ct)
{
var results = new List<CheckResult>();
foreach (var check in checks)
results.Add(await check.Evaluate(context, ct));
return ReleaseDecision.From(results);
}
}
ReleaseGate is unaware of secret checks, migrations and API contracts. It knows one contract and combines the results. New behaviour becomes a new implementation, rather than another branch in a stable core.
4. Composition instead of a deep hierarchy
In an inheritance hierarchy, a change to the base class travels vertically through its subclasses. With composition, an object receives small collaborators and makes its needs explicit.
Deep hierarchy
Explicit composition
Composition does not mean “an interface for every class”. Introduce boundaries where there is variation, a side effect or a need for controlled substitution in tests.
5. SOLID without dogma
How many independent reasons does this module have to change?
Does a new case require editing the stable core?
Does every implementation honour the contract's promise?
Does the client depend only on the capability it needs?
Does the rule depend on a concept or on a particular SDK?
If ReleaseService validates a policy, calls Azure, writes an audit record, formats an email and generates HTML, it has many reasons to change. Separating those responsibilities is about reducing how much you must understand and test when adding the next feature.
6. How to build a new module from scratch
“The system blocks a release if a production secret does not come from an approved source.” Do not start with a class name.
Success, blocking, missing data, timeout and cancellation. Examples reveal the contract.
A pure rule and a separate provider adapter make tests faster and errors clearer.
Pass the data needed for the decision, rather than an entire cloud client or service container.
Rule, input, audit trail, metric and test. Only a second implementation will validate the shared abstraction.
A test, correlated log, metric, operator message and rollback method.
Decision 3 of 4
You have one implementation and no confirmed variation. What do you do?
I build a plugin system immediately
I start simply and leave a clear seam
I connect the rule directly to the SDK
Choose a level of abstraction that matches what you know today.
7. Clean code is not necessarily short code
A function can have twelve lines and mix three responsibilities. Another can have thirty lines while guiding the reader through one coherent algorithm. Evaluate the flow of decisions rather than counting lines.
EvaluateRelease says more than ProcessData.
Less hidden state means fewer conditions to keep in mind.
A negative business decision should be an explicit result.
The name and dependencies show that the code touches the network or database.
Do not copy the same validation into a controller, background job and frontend.
Do not log entire objects containing secrets and private data.
The best name describes the decision the code makes rather than the language construct it uses.
8. How to take over someone else's code
Do not begin by fixing formatting and moving directories. First build a map of behaviour. This is where patience matters more than typing speed.
Record versions, configuration, migrations and a minimal smoke test.
Inputs, outputs, data, integrations, queues and schedules.
What must never happen?
Add characterisation tests for the current behaviour.
An adapter, handler, interface or feature flag.
A small commit, a controlled release and observation.
[Fact]
public async Task Legacy_gate_blocks_when_any_required_check_fails()
{
var checks = new IReleaseCheck[]
{
StubCheck.Pass("contract"),
StubCheck.Block("secrets", "missing vault binding")
};
var decision = await new ReleaseGate(checks)
.Evaluate(Context.Valid(), CancellationToken.None);
decision.Allowed.Should().BeFalse();
decision.Blockers.Should().ContainSingle(x => x.Code == "secrets");
}
A characterisation test does not claim that the old code is perfect. It confirms that we understand its current behaviour. It is a seat belt while we improve the structure.
Decision 4 of 4
Refactoring and a new feature touch the same files. How do you reduce the risk?
I combine everything in one large pull request
I separate protection, refactoring and the feature
I change the code first and add tests later
Choose the strategy that makes review and rollback easier.
9. Red flags in an inherited system
| Signal | What it may mean | The first safe move |
|---|---|---|
| A deep inheritance hierarchy | Hidden state and fragile dependencies | A contract test for subclasses |
switch by type | Superficial polymorphism | A registry of implementations behind a contract |
NotSupportedException | LSP violation | Split the contract into capabilities |
| A service with many SDKs | Many reasons to change | Separate decisions from adapters |
| Many mocks, no integration tests | An unknown system contract | A characterisation test at the boundary |
Utils and Common | No owner for the responsibility | Move the operation to the appropriate concept |
| A boolean controlling a method | Two operations under one name | Separate commands or strategies |
| Logging entire objects | Risk of exposing PII and secrets | An explicit telemetry data model |
10. The same decisions in different languages
Syntax changes, but responsibility boundaries and contracts still matter.
| Intent | C# | Java/Kotlin | TypeScript | Python |
|---|---|---|---|---|
| Small contract | interface | interface | interface or structural type | Protocol / ABC |
| Closed set of results | record + enum | sealed class | discriminated union | dataclass + enum |
| Composing behaviours | constructor | constructor | functions or objects | explicit dependencies |
| Contract test | shared fixture | contract test | shared test suite | parameterised tests |
| Infrastructure adapter | port implementation | port implementation | object satisfying the type | object satisfying the Protocol |
The key questions are universal: where is the decision, where is the side effect, what is the contract, and how will we prove that substituting behaviour is safe?
11. Testing polymorphism
Every implementation returns a result with its own code.
Every implementation respects cancellation.
None returns null instead of a result.
None modifies the input context.
Missing data has an explicit, shared status.
Messages reveal neither secrets nor PII.
A provider failure is distinct from a business block.
The shared contract test works for every type.
12. Do not overengineer the architecture
Do not create an interface merely because a class exists. Do not add a factory if a constructor and the DI container already handle selection. Do not build a plugin system for two stable cases. Do not split methods just to keep each one under five lines.
An abstraction pays off when it reduces the cost of a real change or isolates a real side effect. Good architecture is a little boring: dependencies are explicit, the flow can be traced and a newcomer can predict where the next change belongs.
Interactive flow
Take a change through five safety gates
Run the animation to see the sequence that reduces risk when taking over unfamiliar code.
Run the flow
Ready to run.
Before-change checklist
- I can describe the behaviour in one sentence.
- I know the invariant that must not be broken.
- I know which input triggers this code.
- I can see every side effect.
- I have a test for the current behaviour.
- I have chosen composition or inheritance deliberately.
- Every implementation fulfils the same contract.
- The new abstraction addresses a real axis of change.
- Logs and metrics will let me verify production behaviour.
- Rollback does not require reversing a destructive migration.
The key idea
Good code can still break. What makes it good is that the risk of a change becomes visible early enough. Inheritance can model a stable contract well. Polymorphism can remove central selection statements. SOLID can help identify coupling. None of these techniques replaces understanding the behaviour, testing it and observing it after deployment.
When starting from scratch, work from outcomes and boundaries. When taking over an existing system, observe first, protect its behaviour, find a seam and only then change the structure.
Take this into your project
Five questions more important than your choice of language
Which business decision does this code protect?
Can types be substituted without exceptions to the contract?
Where does the rule end and infrastructure begin?
Which test protects behaviour during refactoring?
Which signal will confirm the change after deployment?
Do you have code that is difficult to change safely?
Start with a map of behaviour and risk before considering a rewrite.
A short description of the module, failure or planned change is enough to identify the first technical step.
Describe your technical problem