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.

01Understandbehaviour and risk
02Protectwith a characterisation test
03Separatecontracts and side effects
04Changeone small vertical slice
05Confirmmetrics and rollback

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.

ContractWhat does the module promise today?

What consumers actually rely on, rather than what was originally intended.

Side effectsWhere does the system touch the outside world?

Database, network, files, queue, cloud, email and telemetry.

EvidenceWhat do the tests protect?

Business behaviour, or only the order of calls to mocks?

The smallest moveWhere can you create a safe seam?

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.

BeforeOne broad contract

Every subclass receives read, write and delete operations, even where they make no sense.

→
AfterCapabilities instead of labels

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.

InputReleaseContextenvironment, artefact, dependencies
01Secrets
02Database
03Contract
ResultReleaseDecisionpass, warn or block

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

BaseReleaseCheck
CloudReleaseCheck
AzureReleaseCheck
ProductionAzureReleaseCheck

Explicit composition

ReleaseGate
IReleaseCheck[]IClockIAuditWriterIReleasePolicy

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

SSingle Responsibility

How many independent reasons does this module have to change?

OOpen/Closed

Does a new case require editing the stable core?

LLiskov Substitution

Does every implementation honour the contract's promise?

IInterface Segregation

Does the client depend only on the capability it needs?

DDependency Inversion

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

01
Name the outcome

“The system blocks a release if a production secret does not come from an approved source.” Do not start with a class name.

02
Write down examples

Success, blocking, missing data, timeout and cancellation. Examples reveal the contract.

03
Separate the decision from I/O

A pure rule and a separate provider adapter make tests faster and errors clearer.

04
Choose the narrowest contract

Pass the data needed for the decision, rather than an entire cloud client or service container.

05
Build a vertical slice

Rule, input, audit trail, metric and test. Only a second implementation will validate the shared abstraction.

06
Define the acceptance evidence

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.

Names reveal intent

EvaluateRelease says more than ProcessData.

Data flows in one direction

Less hidden state means fewer conditions to keep in mind.

Exceptions are exceptional

A negative business decision should be an explicit result.

Side effects are visible

The name and dependencies show that the code touches the network or database.

A rule has one owner

Do not copy the same validation into a controller, background job and frontend.

Telemetry has its own model

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.

1Run

Record versions, configuration, migrations and a minimal smoke test.

2Map

Inputs, outputs, data, integrations, queues and schedules.

3Name the invariants

What must never happen?

4Protect

Add characterisation tests for the current behaviour.

5Find a seam

An adapter, handler, interface or feature flag.

6Change and measure

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

SignalWhat it may meanThe first safe move
A deep inheritance hierarchyHidden state and fragile dependenciesA contract test for subclasses
switch by typeSuperficial polymorphismA registry of implementations behind a contract
NotSupportedExceptionLSP violationSplit the contract into capabilities
A service with many SDKsMany reasons to changeSeparate decisions from adapters
Many mocks, no integration testsAn unknown system contractA characterisation test at the boundary
Utils and CommonNo owner for the responsibilityMove the operation to the appropriate concept
A boolean controlling a methodTwo operations under one nameSeparate commands or strategies
Logging entire objectsRisk of exposing PII and secretsAn explicit telemetry data model

10. The same decisions in different languages

Syntax changes, but responsibility boundaries and contracts still matter.

IntentC#Java/KotlinTypeScriptPython
Small contractinterfaceinterfaceinterface or structural typeProtocol / ABC
Closed set of resultsrecord + enumsealed classdiscriminated uniondataclass + enum
Composing behavioursconstructorconstructorfunctions or objectsexplicit dependencies
Contract testshared fixturecontract testshared test suiteparameterised tests
Infrastructure adapterport implementationport implementationobject satisfying the typeobject 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

1Observeinputs and side effects
2Protecta test of current behaviour
3Find a seamcontract and adapter
4Substituteone small vertical slice
5Confirmmetrics and rollback

Ready to run.

Before-change checklist

  1. I can describe the behaviour in one sentence.
  2. I know the invariant that must not be broken.
  3. I know which input triggers this code.
  4. I can see every side effect.
  5. I have a test for the current behaviour.
  6. I have chosen composition or inheritance deliberately.
  7. Every implementation fulfils the same contract.
  8. The new abstraction addresses a real axis of change.
  9. Logs and metrics will let me verify production behaviour.
  10. 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

01

Which business decision does this code protect?

02

Can types be substituted without exceptions to the contract?

03

Where does the rule end and infrastructure begin?

04

Which test protects behaviour during refactoring?

05

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