Table of Contents
Open Table of Contents
- Introduction
- Before Object-Oriented Programming
- Object-Oriented Programming and SOLID
- Dependencies and the Blast Radius of Change
- Why Interface Design Matters
- Express Interfaces in the Language of the Domain
- Development and Business Working Side by Side
- How Software Design Connects to User Value
- Changing People, Organizations, and Systems Together
Introduction
Hello!
As I continue thinking about what software development should look like, I have found myself returning to one idea: software design is directly connected to delivering value to users. I wrote this article to give that idea a clearer structure. I hope it offers developers, engineering managers, product managers, QA engineers, and others a useful perspective.
Before Object-Oriented Programming
C provides structures: containers that group multiple values into a type of your own. C++, Go, and many other languages have similar constructs, so most developers have probably encountered them. Combine a structure with functions that operate on its values and you have what is often called an abstract data type.
#include <stdbool.h>
typedef struct {
int balance;
} BankAccount;
void deposit(BankAccount *account, int amount) {
account->balance += amount;
}
bool withdraw(BankAccount *account, int amount) {
if (amount > account->balance) {
return false;
}
account->balance -= amount;
return true;
}
The important idea was to place data near the functions that operate on it and provide developers with an understandable interface. Keep that in mind, because it remains central to object-oriented design.
Abstract data types still had limitations:
- Data and functions remained separate, making the intended operations harder to discover.
- Changes to how data was handled forced changes in the functions, reducing maintainability.
- Outside code could manipulate data directly and violate its invariants.
Classes emerged as a major extension of this idea. Introductory books often explain classes by mapping objects in the real world—an apple, a giraffe, and so on—into software. I have never found that explanation especially useful.
A more practical description is simpler: a class is a container that brings data and functions together while hiding how the data is manipulated. In that sense, a class is a natural continuation of the abstract data type.
Object-Oriented Programming and SOLID
Classes are commonly associated with three major features: encapsulation, inheritance, and polymorphism. Using these tools made object-oriented design the dominant approach to building many kinds of systems.
Having the tools did not mean people immediately knew how to use them well. Procedural habits inherited from C remained strong, and developers struggled to turn data locality, inheritance, and polymorphism into maintainable system design. SOLID emerged as a set of design principles for that situation:
- S: Single Responsibility Principle
- O: Open/Closed Principle
- L: Liskov Substitution Principle
- I: Interface Segregation Principle
- D: Dependency Inversion Principle
This article focuses on the Single Responsibility Principle and the Dependency Inversion Principle.
Single Responsibility Principle
The Single Responsibility Principle says that a class should have only one reason to change. A responsibility does not mean a single method; it means a cohesive set of behavior that changes for the same reason.
Suppose one class calculates an order total, generates a PDF invoice, and sends a confirmation email:
// Bad: pricing, invoice generation, and email delivery are separate responsibilities.
final class OrderService
{
/**
* Calculate the order total.
*
* Problem: every pricing-rule change requires this class to change.
*
* @param list<int> $itemPrices
*/
public function calculateTotal(array $itemPrices): int
{
return array_sum($itemPrices);
}
/**
* Generate a PDF invoice.
*
* Problem: changes to the document or PDF library also affect this class.
*
* @param array{orderNumber: string, total: int} $order
*/
public function generateInvoicePdf(array $order): string
{
// Generate the invoice with a PDF library.
return 'invoice.pdf';
}
/**
* Send an order confirmation email.
*
* Problem: email copy and delivery changes affect this class as well.
*/
public function sendConfirmationEmail(string $emailAddress, string $orderNumber): void
{
// Send the confirmation through an email service.
}
}
OrderService must change for unrelated reasons: pricing rules, document presentation, and email content. A class carrying so many concerns is more likely to break unintentionally whenever any one of them changes.
Separating the responsibilities confines each kind of change:
// Better: each class has one responsibility and one kind of reason to change.
final class OrderTotalCalculator
{
/**
* Pricing-rule changes remain inside this class.
*
* @param list<int> $itemPrices
*/
public function calculate(array $itemPrices): int
{
return array_sum($itemPrices);
}
}
final class InvoicePdfGenerator
{
/**
* Invoice layout and PDF-library changes remain inside this class.
*
* @param array{orderNumber: string, total: int} $order
*/
public function generate(array $order): string
{
return 'invoice.pdf';
}
}
final class OrderConfirmationMailer
{
/** Email copy and delivery changes remain inside this class. */
public function send(string $emailAddress, string $orderNumber): void
{
// Send the order confirmation.
}
}
Now a pricing change is contained in OrderTotalCalculator, an invoice change in InvoicePdfGenerator, and an email change in OrderConfirmationMailer. Consumers depend on less functionality, and every change has a smaller blast radius.
Dependency Inversion Principle
The Dependency Inversion Principle says that high-level policy and low-level details should both depend on abstractions rather than concrete implementations. High-level policy includes core business rules such as accepting an order or finalizing a payment. Databases, payment providers, and email services are implementation details that support those rules.
If Class A uses Class B directly, the relationship looks like this:
flowchart LR
classA[Class A] -->|depends on| classB[Class B]
Class A depends on Class B’s interface. If that interface or its usage changes, Class A must change too. Even an internal implementation change may leak into Class A when the design exposes too many details.
Instead, introduce Interface B’, which describes the capability Class B provides. Class A depends on the interface, and Class B implements it:
flowchart LR
classA[Class A] -->|depends on| interfaceB[Interface B']
classB[Class B] -->|implements| interfaceB
With Class A → Interface B' ← Class B, Class A only needs to understand the abstraction. As long as that contract remains stable, Class B can change its internal algorithm or libraries without affecting Class A.
For example, the use case that finalizes an order can depend on an interface meaning “can execute a payment,” while each payment provider implements that interface. The business rule no longer needs to know a provider’s details. New providers, replacements, and test implementations can be introduced with minimal impact on the core flow.
Dependencies and the Blast Radius of Change
With those principles in mind, consider class dependencies again:
flowchart LR
classA[Class A] -->|depends on| classB[Class B]
Class A refers to Class B’s interface to use its functionality. A change to that interface affects Class A, while a change to Class A does not affect Class B. The direction of dependency therefore determines the direction in which change propagates.
In this example, Class A’s fragility partly depends on changes to Class B. Real systems contain far more than two classes and build a dense network of relationships:
flowchart LR
classA[Class A] --> classB[Class B]
classA --> classC[Class C]
classB --> classD[Class D]
classB --> classE[Class E]
classC --> classE
classC --> classF[Class F]
classD --> classF
classE --> classG[Class G]
classF --> classG
classG --> classB
The Single Responsibility Principle reduces unnecessary edges in that network. When one class owns several responsibilities, more consumers depend on it, so a small change can affect many places. Limiting a class to one responsibility minimizes dependencies and contains change.
Dependency inversion changes the direction of an edge by placing an abstraction between components. Stable core behavior can define the abstraction it needs, protecting it from changes in volatile details.
Why Interface Design Matters
Interface design is essential to controlling these dependencies. Poor interfaces gradually invite duplicate functions and increasing complexity, much like broken windows encouraging further neglect.
What makes an interface good? It hides implementation details. A consumer supplies meaningful input and receives the expected output without needing to understand everything happening inside.
Think of a washing machine. Most people do not understand its mechanism in detail, yet they can load clothes, detergent, and fabric softener, press the right button, wait, and receive clean laundry. A good software interface creates the same experience: correct input reliably produces correct output without exposing the mechanism.
Consider this interface:
// Bad: neither the names nor parameters reveal the business operation,
// and the caller must provide database connection details.
final class Service
{
/**
* @param array<string, mixed> $arg1
* @return array<string, mixed>
*/
public function execute(array $arg1, string $arg2): array
{
$pdo = new PDO($arg2);
$statement = $pdo->prepare('UPDATE orders SET status = :status WHERE id = :id');
$statement->execute([
'id' => $arg1['id'],
'status' => $arg1['status'],
]);
return ['result' => true];
}
}
The consumer must understand the implementation to use the method. That knowledge should not be necessary.
Here is a clearer design:
final readonly class OrderId
{
/** @param non-empty-string $value */
public function __construct(public string $value)
{
}
}
final readonly class PaymentMethod
{
/** @param non-empty-string $value */
public function __construct(public string $value)
{
}
}
final readonly class PaymentResult
{
/** @param non-empty-string $transactionId */
public function __construct(public string $transactionId)
{
}
}
// Better: expose the business operation of confirming payment for an order.
final class OrderPaymentService
{
/**
* The caller does not need to know about database connections or providers.
*/
public function confirm(OrderId $orderId, PaymentMethod $paymentMethod): PaymentResult
{
// Payment and persistence details remain encapsulated here.
return new PaymentResult('transaction-id');
}
}
Clear class names, method names, parameters, and return values tell the consumer what the operation does, what it needs, and what it produces.
This principle applies everywhere: class and method contracts, Web API requests and responses, database tables, and frontend component props. Interface design is at the heart of all of them.
Express Interfaces in the Language of the Domain
Every system exists to solve a problem: accounting, ordering products, processing payments, or something else. The problem space a system addresses is its domain.
Good interfaces use the language of that domain. In the improved example, precise names and types made the operation’s meaning obvious. Developers spend a large share of their time reading code, so the system should project the domain model into its implementation.
That requires the development team to understand the product’s domain and business deeply. When interfaces use domain language, developers can express business behavior as executable code.
Becoming experts in both software engineering and every detail of a domain is difficult. In practice, close communication with product owners, product managers, and business leaders is necessary to turn that knowledge into self-documenting code—code that teaches its reader how the product and business work.
Development and Business Working Side by Side
How can development and business collaborate that closely? A waterfall-style customer–supplier relationship, with a wall between planning and implementation, cannot create this world.
What do we ultimately gain from good software design? Properly controlled dependencies minimize development lead time and make the fastest practical release possible. Teams can test hypotheses in shorter cycles and deliver useful capabilities to customers sooner.
This is where agile development enters the picture. Agile teams bring development and business together, communicate continuously, release in short cycles, and repeatedly test whether they are delivering value. To move quickly, developers must understand the business and business teams must understand development.
Good interface design is one important way for a development team to understand the business. Good design and delivering user value are therefore closely connected.
How Software Design Connects to User Value
Look back at the path we followed: structures, object-oriented classes, SOLID, dependencies, interface design, and agile development. We began with code and arrived at organizations and user value.
What does that tell us?
Software design, agile development, and delivering value to users are all connected along one continuous line.
As a software engineer, you may have learned programming, design approaches such as DDD and Clean Architecture, and methods such as agile and Scrum as separate subjects. They look different, so their relationship can be hard to see. The progression in this article shows that they are continuous parts of the same activity.
Changing People, Organizations, and Systems Together
From that perspective, dividing software development into “upstream” and “downstream” work makes little sense. Japanese software projects are often described as a world where project managers perform upstream planning and programmers receive outsourced downstream implementation work.
Those layers cannot truly be separated. If code design determines how quickly value reaches users and ultimately affects business revenue, then so-called downstream design necessarily influences upstream decisions from the bottom up. The healthiest model is one development team taking end-to-end responsibility, from the earliest decision to the final implementation.
This leads to another conclusion: people, organizations, and systems must change together.
Changing only the system cannot produce the best result when the organizational structure works against it. Changing only the organization will not create a positive feedback loop when the system’s shape does not fit the new organization. And changing both requires hiring and developing people capable of making that transformation real.
The ending may sound abstract, but this is the world we need to move toward. Software design is one of the tools that lets us make that change and deliver more value to users.