Software architecture can sound grander than it really is.

Mention architecture and it is easy to picture diagrams full of boxes, arrows, services, repositories, factories and interfaces. In practice, much of good architecture comes down to something considerably simpler:

Make it easy to change one part of the software without accidentally breaking another.

That matters just as much in a Delphi desktop application as it does in a large web service.

Perhaps more so.

Delphi makes it very easy to build software quickly. Drop some controls on a form, double-click a button, write some code, call another unit, update a dataset, write something to the registry and carry on.

That productivity is one of Delphi's great strengths.

It can also make it surprisingly easy for unrelated responsibilities to become tangled together.

A form starts by displaying some data. Then it starts loading the data. Then validating it. Then saving it. Then printing it. Then sending an HTTP request. Then manipulating a licence key.

Before long, the form isn't really a form any more.

It is the application.

One phrase I use when thinking about this is:

Don't make one thing do two things.

It is deliberately simplistic, but it is a remarkably useful architectural rule.

If a class is responsible for two unrelated things, there is a good chance those responsibilities will eventually need to change for different reasons.

And that is where the trouble starts.

Good Architecture Is Mostly About Change

Working software is not difficult to recognise.

You press the button and the right thing happens.

Well-architected software is harder to see.

Its benefits usually appear later.

A requirement changes.

A new file format is introduced.

A second implementation is needed.

A feature has to work somewhere other than the form where it was originally written.

A third-party API changes.

Something needs unit testing.

Suddenly the structure of the code matters enormously.

Two applications can behave identically today while being completely different to maintain tomorrow.

The architectural question is therefore not simply:

Does this code work?

It is:

How much unrelated code will I have to understand, modify and retest when this requirement changes?

That is where concepts such as SOLID, coupling and cohesion become useful.

They are not academic rules to obey for their own sake.

They are tools for keeping the cost of change under control.

Coupling: When Classes Know Too Much About Each Other

Two classes are coupled when one depends on details of the other.

Some coupling is unavoidable.

If one class calls another, some relationship obviously exists.

The problem is tight coupling, where the caller depends heavily on the exact implementation, state or internal behaviour of another class.

This is also where one of the fundamental ideas of object-oriented programming β€” encapsulation β€” becomes important. A class should expose the behaviour other code needs while keeping its internal implementation private. The more callers need to understand how a class works internally, the weaker that encapsulation becomes and the tighter the coupling gets.

Consider a typical Delphi form:

procedure TfrmCustomer.btnSaveClick(Sender: TObject);
var
  Json: TJSONObject;
begin
  ValidateCustomerFields;

  Json := BuildCustomerJson;
  try
    HttpClient.Post(ApiUrl, Json.ToJSON);

    Registry.WriteString('LastCustomer', edtName.Text);

    ShowMessage('Customer saved');
  finally
    Json.Free;
  end;
end;

It may work perfectly well.

But look at what the form now knows about:

  • validation;
  • JSON generation;
  • HTTP communication;
  • API URLs;
  • registry persistence;
  • UI notification.

The button handler is tightly coupled to nearly the entire operation.

If the API changes, the form changes.

If registry storage changes, the form changes.

If validation rules change, the form changes.

If the same save operation is required somewhere without a GUI, most of the code cannot easily be reused.

This is the real cost of tight coupling.

It isn't that the code looks ugly.

It is that unrelated changes propagate through the application.

Loose Coupling Creates Boundaries

Loose coupling means reducing how much one part of the program needs to know about another.

Instead of the form knowing how customers are validated, serialised and transmitted, it might know only this:

procedure TfrmCustomer.btnSaveClick(Sender: TObject);
begin
  FCustomerService.Save(CustomerFromForm);
end;

The form knows that a customer service exists and that it can save a customer.

It doesn't care whether that means:

  • sending JSON over HTTP;
  • writing to a database;
  • saving a file;
  • queuing the operation;
  • talking to a completely different server.

That knowledge belongs somewhere else.

The service encapsulates the details and exposes only the operation the form actually needs.

The dependency has become narrower.

That is loose coupling.

And loose coupling gives us something extremely valuable:

The ability to replace one piece without rewriting all the pieces around it.

Loose Coupling Does Not Mean Interfaces Everywhere

In Delphi discussions, loose coupling sometimes gets translated into:

Everything must have an interface.

That can easily go too far.

Interfaces are useful when there is a meaningful boundary or when different implementations are plausible.

For example:

type
  ICustomerRepository = interface
    function LoadCustomer(const Id: Integer): TCustomer;
    procedure SaveCustomer(const Customer: TCustomer);
  end;

A service using ICustomerRepository does not care whether the repository stores customers in SQL Server, SQLite, a REST service or an in-memory test implementation.

That is a useful abstraction.

But creating an interface for every class simply because SOLID mentions abstractions usually adds noise without adding flexibility.

Good architecture is not about maximising the number of interfaces.

It is about putting boundaries where change is likely to occur.

Cohesion: Keep Related Things Together

Coupling describes relationships between classes.

The other half of the equation is cohesion.

A cohesive class has responsibilities that naturally belong together.

For example, a class responsible for parsing a template file might reasonably contain:

  • file loading;
  • XML parsing;
  • node traversal;
  • extracting template metadata.

Those operations all contribute to one purpose.

A class which parses template files, validates email addresses, prints reports and writes licence information to the registry does not have high cohesion.

Those responsibilities happen to be located in the same class, but they do not belong together.

A useful rule is:

High cohesion inside classes, low coupling between classes.

That one sentence describes a considerable amount of good software architecture.

SOLID

The SOLID principles are five related design principles intended to make software easier to understand, extend and maintain.

They are frequently explained using artificial examples involving animals, shapes or coffee machines.

In Delphi, the principles make rather more sense when applied to the kinds of classes we actually write.

S β€” Single Responsibility Principle

The Single Responsibility Principle is commonly summarised as:

A class should have one reason to change.

This aligns closely with:

Don't make one thing do two things.

Suppose we have:

type
  TInvoiceManager = class
  public
    procedure CalculateInvoice;
    procedure SaveInvoice;
    procedure PrintInvoice;
    procedure EmailInvoice;
  end;

At first glance this might look perfectly sensible. Everything concerns invoices.

But there are several completely different reasons this class might need modification.

The calculation rules may change.

Database storage may change.

Printing may change.

The email provider may change.

Those responsibilities are related only because they happen to act upon the same data.

A better design might separate them:

TInvoiceCalculator
TInvoiceRepository
TInvoicePrinter
TInvoiceMailer

Now changes to printing do not require editing the invoice calculation class.

This doesn't mean every class should contain a single method.

It means its methods should serve a single coherent responsibility.

That distinction is important.

S Doesn't Mean Tiny Classes

The Single Responsibility Principle is sometimes taken to absurd extremes.

If taken literally, it can produce hundreds of tiny classes which merely pass calls between one another.

That can make software harder to understand rather than easier.

The goal is not:

One method per class.

The goal is:

One conceptual job per class.

A parser may have twenty methods and still have one responsibility.

A form with five event handlers may already have several.

O β€” Open/Closed Principle

The Open/Closed Principle says that software should be:

Open for extension, but closed for modification.

That sounds rather abstract, but the idea is straightforward.

If adding a new behaviour always requires modifying a large case statement scattered throughout the application, the system is difficult to extend.

Imagine supporting different export formats:

case ExportType of
  etCSV: ExportCSV;
  etXML: ExportXML;
  etJSON: ExportJSON;
end;

There is nothing inherently wrong with this.

But if similar case statements appear in ten different units, adding PDF export now requires modifying ten places.

An alternative is to give each exporter a common contract:

type
  IExporter = interface
    procedure Export(const FileName: string);
  end;

Then implementations can be added:

TCSVExporter
TXMLExporter
TJSONExporter

Existing code can continue using IExporter.

This is polymorphism doing useful architectural work. Different classes provide different behaviour through the same contract, so the calling code does not need to know which concrete exporter it has been given.

A new exporter extends the system without requiring existing exporter implementations to change.

That is the practical value of Open/Closed.

Again, judgement matters.

A three-line case statement which is unlikely ever to change does not need an architecture astronaut arriving with six interfaces and an abstract factory.

L β€” Liskov Substitution Principle

The Liskov Substitution Principle is often the least intuitively explained SOLID principle.

In practical terms:

If one class claims to be a specialised version of another, code using the base class should still work correctly when given the specialised class.

This is fundamentally about using inheritance correctly.

Inheritance is one of the core mechanisms of object-oriented programming, and Delphi makes it especially natural. A descendant class can reuse and extend behaviour from its ancestor, but that convenience can tempt us into creating inheritance relationships merely because two classes share some code.

That is not enough.

Suppose:

type
  TDocument = class
  public
    procedure Save; virtual;
  end;

and:

type
  TReadOnlyDocument = class(TDocument)
  public
    procedure Save; override;
  end;

If TReadOnlyDocument.Save simply raises:

raise Exception.Create('Cannot save a read-only document');

then there is a design problem.

Code accepting TDocument reasonably expects that calling Save is valid.

The subclass violates that expectation.

The inheritance relationship is therefore questionable.

Perhaps read-only behaviour should instead be represented separately, or saving should belong to another abstraction.

Liskov is really about preserving behavioural promises.

Inheritance should mean:

You can use this specialised object anywhere you can use the base object.

Not merely:

These two classes happen to share some code.

Used properly, inheritance combines neatly with polymorphism: calling code can work against a base type while descendants provide specialised behaviour. Used badly, inheritance simply creates hidden assumptions and fragile class hierarchies.

I β€” Interface Segregation Principle

The Interface Segregation Principle says that callers should not be forced to depend on operations they do not need.

Suppose we define:

type
  IDocumentManager = interface
    procedure Load;
    procedure Save;
    procedure Print;
    procedure Email;
    procedure Export;
    procedure Archive;
  end;

A class that only needs to print now depends on an interface containing six unrelated operations.

A better design may be:

type
  IDocumentLoader = interface
    procedure Load;
  end;

  IDocumentSaver = interface
    procedure Save;
  end;

  IDocumentPrinter = interface
    procedure Print;
  end;

A class can depend only on what it actually uses.

This reduces coupling and makes implementations easier to replace and test.

Delphi's ability for a class to implement several interfaces makes this particularly straightforward.

D β€” Dependency Inversion Principle

The Dependency Inversion Principle is probably the SOLID principle with the greatest practical effect on coupling.

The principle says that higher-level code should depend on abstractions rather than concrete low-level implementations.

Consider:

type
  TRegistrationService = class
  private
    FHttpClient: TNetHTTPClient;
  public
    procedure Submit;
  end;

TRegistrationService is directly coupled to TNetHTTPClient.

That may be perfectly acceptable.

But suppose submission becomes important enough that we want to unit test it without making real network requests.

Now that dependency becomes inconvenient.

We might instead define:

type
  IHttpTransport = interface
    function Post(const URL, Data: string): string;
  end;

The service depends on:

IHttpTransport

while a separate class implements that interface using TNetHTTPClient.

Tests can provide a fake implementation.

Production code provides the real one.

The high-level registration logic no longer knows how HTTP is performed.

That is dependency inversion.

Dependency Injection Is Simpler Than It Sounds

Dependency inversion often leads to dependency injection, another term which sounds much more exotic than it is.

Instead of a class creating its own dependency:

FRepository := TCustomerRepository.Create;

someone provides it:

constructor TCustomerService.Create(const Repository: ICustomerRepository);
begin
  FRepository := Repository;
end;

That is dependency injection.

Nothing magical is happening.

We have simply moved responsibility for choosing the implementation outside the class.

That small change can dramatically improve testability and flexibility.

And no, it does not mean a Delphi application needs a dependency injection framework.

Constructor parameters work extremely well.

Tight Coupling Spreads

One unpleasant property of tight coupling is that it tends to propagate.

A form directly creates a service.

The service directly creates a repository.

The repository directly accesses configuration.

Configuration directly accesses the registry.

Soon, testing the service requires:

  • a form;
  • a registry;
  • a database;
  • configuration files;
  • perhaps even a live server.

Each class has dragged the next dependency into the test.

This is why tightly coupled applications gradually become difficult to change.

Every component is attached to everything beneath it.

The code may still be divided into beautifully named units, but the architecture is effectively one large object.

Loose Coupling Localises Change

With looser coupling:

Form
  ↓
Service
  ↓
Repository interface
  ↓
Repository implementation

the boundaries are clearer.

The form owns presentation.

The service owns the operation.

The repository owns persistence.

The implementation owns the database details.

If the database changes, the form does not need to know.

If the UI changes, the repository does not care.

If the service is tested, a fake repository can be supplied.

Change becomes local.

And local change is cheaper change.

Forms Are Particularly Easy to Overload

Delphi's event-driven model makes forms particularly vulnerable to accumulating responsibilities.

It usually starts innocently:

procedure TfrmMain.btnProcessClick(Sender: TObject);
begin
  ...
end;

A little validation is added.

Then some data transformation.

Then a database update.

Then logging.

Then error handling.

Then somebody needs the same behaviour from another form.

Now there is a decision.

Copy the code?

Move it into a common unit?

Call the first form?

None of these feels particularly attractive because the real problem occurred much earlier.

Business logic was allowed to become UI logic.

A useful rule is:

Forms should coordinate; other classes should do the work.

A form should ideally be responsible for things such as:

  • reading control values;
  • displaying state;
  • enabling and disabling actions;
  • showing validation errors;
  • responding to user interaction.

It should not normally need to know the details of:

  • SQL;
  • HTTP protocols;
  • XML parsing;
  • licence algorithms;
  • file formats;
  • complex business rules.

That separation alone can make a large Delphi application substantially easier to maintain.

Unit Boundaries Are Not Architecture

Delphi encourages organising code into units.

But moving methods into another unit does not automatically create good architecture.

If UnitA knows every implementation detail of UnitB, and UnitB relies heavily on globals from UnitC, the code is still tightly coupled.

The important boundary is not the .pas file.

It is the dependency.

Ask:

What does this class need to know about the class it is using?

The less implementation detail required, the cleaner the boundary.

Global State Is Coupling in Disguise

Global variables, global forms and singleton-style application objects are convenient.

They also create invisible dependencies.

Consider:

if formMain.AppSettings.EnableFeature then
  ...

The method appears to take no dependencies.

In reality it depends on:

  • formMain;
  • its lifetime;
  • its settings property;
  • application initialisation;
  • perhaps several other objects reachable through it.

That dependency is merely hidden.

Passing what the method actually needs is often clearer:

procedure ProcessDocument(const Settings: TAppSettings);

Now the dependency is visible.

Visible dependencies are easier to reason about.

Testability Is Often a Side Effect of Good Architecture

People sometimes design code specifically "for unit testing".

That can make testing sound like an additional architectural burden.

In reality, testability is often simply evidence that responsibilities have been separated successfully.

If a class can be tested without constructing half the application, it probably has clean boundaries.

If testing a validation class requires creating a form, connecting a database and launching an HTTP server, the architecture is telling you something.

Tests are therefore useful even when relatively few unit tests are eventually written.

The question:

How would I test this class independently?

is itself an excellent architectural diagnostic.

Don't Abstract Things Just Because You Can

There is an opposite failure mode.

Once developers discover SOLID, interfaces and dependency injection, it becomes tempting to abstract everything.

Instead of:

TFile.ReadAllText(FileName);

we suddenly acquire:

IFileReader
TFileReader
IFileReaderFactory
TFileReaderFactory
IFileReaderProvider

This is not loose coupling.

It is bureaucracy.

Every abstraction has a cost.

There is another name for a class with exactly one implementation, no plausible alternative, no testing requirement and no independent reason to change:

A class.

Good architecture requires recognising useful boundaries, not manufacturing them everywhere.

A More Useful Question Than β€œIs This SOLID?”

Rather than reviewing code against five principles mechanically, I find it more useful to ask practical questions.

When looking at a class:

  • What is this class actually responsible for?
  • Could I describe its job in one sentence without using the word β€œand”?
  • How many unrelated reasons could cause it to change?
  • Does it know details it shouldn't need to know?
  • Could I replace one of its dependencies without rewriting it?
  • Could I test it without constructing half the application?
  • Is the UI performing business logic?
  • Is a base class making promises that subclasses cannot honour?
  • Does an interface contain methods most implementations do not need?
  • Am I creating an abstraction because it solves a real problem, or because architecture diagrams look impressive?

That first question alone catches a surprising number of problems.

And the second is essentially another version of:

Don't make one thing do two things.

Architecture Is About Containing Damage

No architecture prevents change.

Requirements will still change.

APIs will still change.

Customers will still request things nobody anticipated.

Libraries will still be replaced.

Twenty-year-old code will still contain methods whose original purpose has been lost to history.

The aim of architecture is not to predict all of this.

It is to contain the damage when it happens.

When a requirement changes, ideally there should be an obvious place where that behaviour lives.

Change it there.

Test it there.

Move on.

The alternative is searching the application for twelve subtly different implementations of the same rule and hoping number thirteen is not hiding inside a button click somewhere.

Keep the Boundaries Honest

SOLID is useful because all five principles ultimately encourage the same broader behaviour:

Keep responsibilities and dependencies under control.

Single Responsibility keeps unrelated behaviour apart.

Open/Closed helps new behaviour arrive without destabilising existing code, often through polymorphism.

Liskov keeps inheritance relationships honest.

Interface Segregation prevents abstractions becoming bloated.

Dependency Inversion stops high-level behaviour becoming welded to low-level implementation details.

Encapsulation keeps implementation details behind clear boundaries, inheritance provides a way to specialise behaviour where a true substitutable relationship exists, and polymorphism allows callers to work with abstractions rather than concrete implementations.

Together, they encourage high cohesion and loose coupling.

But SOLID is not a checklist that automatically produces good software.

The principles still require judgement.

Sometimes a direct dependency is perfectly reasonable.

Sometimes a case statement is simpler than polymorphism.

Sometimes inheritance is the right model.

Sometimes composition is cleaner.

Sometimes an interface adds genuine flexibility.

Sometimes it adds absolutely nothing.

The goal is not maximum abstraction.

The goal is software where the structure reflects the responsibilities.

When one thing needs to change, one thing should change.

Which brings us back to the simplest version of the rule:

Don't make one thing do two things.

It will not solve every architectural problem.

But it is a remarkably good place to start.