Why Good Software Architecture Is About Change, Not Layers

Good architecture is not about adding more folders, services, or patterns. It is about designing software so that important changes stay understandable, local, and safe.

Modern software architecture concept showing connected modular components, clean code structure, and system boundaries.
Modern software architecture concept showing connected modular components, clean code structure, and system boundaries.

Why Good Software Architecture Is About Change, Not Layers

Software architecture is often introduced through diagrams.

Controllers sit at the top. Services live somewhere in the middle. Repositories handle persistence. Models represent data. Utilities collect everything that does not fit anywhere else.

The diagram looks organized.

The folders look clean.

Then a small feature request arrives.

Suddenly, one business change requires updates across seven files, four directories, three abstractions, and a configuration layer nobody remembers creating.

That is the point where architecture stops being about appearance and starts being about reality.

Good software architecture is not measured by how elegant the folder tree looks.

It is measured by how well the system handles change.

Architecture is a bet on the future

Every architectural decision makes an assumption about what is likely to change.

When we create a module, define an interface, split a service, introduce an abstraction, or separate one responsibility from another, we are making a prediction.

We are effectively saying:

These things will probably change independently.

Sometimes that prediction is correct.

Sometimes it is not.

Imagine a publishing platform with this structure:

controllers/
services/
repositories/
models/
validators/
helpers/

Technically, everything has a place.

But consider a new requirement:

Authors with inactive accounts should no longer be allowed to publish articles.

That single business rule might require changes in:

controllers/article-controller.ts
services/article-service.ts
validators/article-validator.ts
repositories/author-repository.ts

The system is separated by technical type, but the business decision is scattered.

Now imagine organizing the same capability around the change itself:

articles/
  publishing/
    can-publish.ts
    publish-article.ts
    publication-policy.ts
    publication-errors.ts

The publishing decision now lives near the rest of the publishing behavior.

The architecture reflects the way the software changes.

That is often more useful than reflecting the technologies used to build it.

Layers are not architecture

Layers are a tool.

They are not the goal.

A layered application might look like this:

Presentation
    ↓
Application
    ↓
Domain
    ↓
Infrastructure

This separation can be extremely useful.

It can prevent database concerns from leaking into business logic.

It can make testing easier.

It can give teams clearer responsibilities.

But simply creating those folders does not automatically produce good architecture.

A project can have perfect-looking layers while still having:

  • unclear responsibilities,
  • circular dependencies,
  • duplicated business rules,
  • tightly coupled modules,
  • unnecessary abstractions,
  • and changes that spread everywhere.

Architecture should solve actual problems.

Not merely reproduce diagrams from books.

Measure architecture by the cost of change

Suppose someone asks:

How good is this architecture?

One useful response is another question:

What happens when we need to change it?

Consider a simple requirement:

Published articles may now be scheduled for automatic expiration.

In one system, implementing this might require changing:

  • the database schema,
  • the article model,
  • the publishing service,
  • the homepage query,
  • the API,
  • the admin panel,
  • the cache,
  • the search index,
  • and several unrelated helpers.

In another system, expiration may already belong to a well-defined publishing lifecycle.

The same feature could be implemented mostly inside one module.

That difference is architectural.

Good architecture reduces the blast radius of change.

It does not mean every change touches one file.

Real features often cross multiple parts of a system.

The goal is that the places being changed make sense together.

Group by responsibility, not convenience

A common project structure begins like this:

helpers/
utils/
services/
common/
misc/

At first, these folders feel convenient.

Then they slowly become storage rooms.

A formatter goes into utils.

Authentication logic goes into helpers.

An email function goes into common.

Payment validation appears in services.

After a year, nobody can predict where new functionality belongs.

This creates a subtle form of architectural debt.

Developers begin finding code through search instead of understanding the system's structure.

Compare that with:

auth/
billing/
articles/
notifications/
analytics/

These boundaries communicate something immediately.

Even before opening the files, a developer can understand the major responsibilities of the application.

Architecture should help people form a mental model.

If the folder structure does not communicate what the software actually does, it is probably organizing the wrong thing.

Keep business rules close to the domain

Consider this condition:

if (
  article.status === "reviewed" &&
  author.isActive &&
  article.wordCount >= 500
) {
  publish(article);
}

Where should this logic live?

Technically, it could live almost anywhere.

A controller could check it.

A validator could check it.

A service could check it.

The UI could disable the button.

But this is not merely technical validation.

It is a business rule.

The rule describes what the publishing system considers publishable.

That makes it part of the publishing domain.

We can express it directly:

function canPublish(
  article: Article,
  author: Author
): boolean {
  return (
    article.status === "reviewed" &&
    author.isActive &&
    article.wordCount >= MINIMUM_ARTICLE_LENGTH
  );
}

Now the decision has a home.

If the publication policy changes later, developers know where to look.

This is one of the most useful architectural principles:

Put decisions near the concepts that justify them.

Avoid abstractions without pressure

Developers often hear:

Do not repeat yourself.

That advice is useful.

It can also create terrible abstractions.

Suppose two functions happen to look similar:

function publishArticle(article: Article) {
  validateArticle(article);
  saveArticle(article);
  notifySubscribers(article);
}

and:

function approveComment(comment: Comment) {
  validateComment(comment);
  saveComment(comment);
  notifyAuthor(comment);
}

Someone might notice the pattern and create:

function processContent(
  content,
  validator,
  saver,
  notifier
) {
  validator(content);
  saver(content);
  notifier(content);
}

Technically, duplication has been removed.

Architecturally, almost nothing has improved.

Article publishing and comment approval are different business processes.

Their similarity may be accidental.

When either process evolves, the abstraction may become increasingly complicated:

processContent(
  content,
  validator,
  saver,
  notifier,
  options,
  hooks,
  callbacks,
  skipValidation,
  mode
);

The abstraction becomes harder to understand than the duplication it replaced.

A better principle is:

Abstract when multiple things must change together, not merely because they currently look similar.

Duplication can be cheaper than the wrong abstraction

Two pieces of code that look similar today may move in completely different directions tomorrow.

Suppose both customers and administrators currently use the same login workflow.

It may be tempting to create one highly generalized authentication system.

But their requirements may soon diverge.

Administrators might require:

  • two-factor authentication,
  • stricter session limits,
  • IP logging,
  • account lockout rules,
  • and audit trails.

Customers might require:

  • social login,
  • passwordless authentication,
  • device recognition,
  • and longer sessions.

If the architecture forces both workflows through one abstraction, every new requirement adds conditional logic.

if (user.type === "admin") {
  // ...
} else if (user.type === "customer") {
  // ...
}

Eventually, the abstraction stops removing complexity.

It concentrates it.

Sometimes keeping two small pieces of code separate provides more freedom than creating one shared system too early.

Dependencies should point toward stability

Some parts of a system change frequently.

Others represent relatively stable business concepts.

For example, the database library might change.

The HTTP framework might change.

The email provider might change.

But a rule such as:

An archived article cannot be published.

may remain stable for years.

If business logic depends directly on implementation details, infrastructure changes can ripple through the core system.

Consider:

import mysql from "some-mysql-library";

function publishArticle(id: number) {
  const article = mysql.query(...);
  // business logic
}

The business operation is now tied directly to one storage mechanism.

Instead, the publishing logic can depend on a capability:

interface ArticleRepository {
  findById(id: number): Promise<Article>;
  save(article: Article): Promise<void>;
}

The publishing process depends on what it needs, not on how storage happens.

This does not mean every class needs an interface.

Adding abstractions everywhere creates its own complexity.

The important question is:

Is this dependency likely to create expensive coupling when one side changes?

If yes, creating a boundary may be worth it.

Make important workflows visible

Some systems hide their most important behavior across event handlers, middleware, callbacks, queues, and hooks.

For example, publishing an article might trigger:

Article saved
    ↓
Observer detects status change
    ↓
Event emitted
    ↓
Queue job created
    ↓
Search index updated
    ↓
Subscribers notified
    ↓
Cache invalidated

Event-driven architecture can be powerful.

But if the workflow becomes impossible to trace, flexibility has been purchased with comprehension.

For critical business processes, it should be possible to answer:

  • What starts this workflow?
  • What steps occur?
  • Which steps are synchronous?
  • Which steps may fail?
  • What happens if one fails?
  • Can the process be safely retried?

If developers cannot answer these questions without searching the entire repository, the architecture is hiding too much.

Design failure paths alongside success paths

Architecture discussions often focus on successful behavior.

A payment succeeds.

An article publishes.

A user registers.

A notification is delivered.

Production systems spend a surprising amount of time doing something else.

Networks time out.

APIs return errors.

Jobs run twice.

Users refresh pages.

Databases temporarily disappear.

Email providers reject messages.

Good architecture makes failure behavior explicit.

Suppose publishing an article triggers a notification.

What should happen if the notification fails?

Should the article become unpublished?

Probably not.

The system might instead treat publication and notification as separate outcomes:

Article publication: successful
Subscriber notification: pending retry

That decision determines architecture.

It affects transaction boundaries, queues, retry logic, error handling, and monitoring.

Failure is not something to add later.

Failure behavior is part of system design.

Transactions reveal real boundaries

Database transactions can expose whether responsibilities have been separated correctly.

Imagine creating an order:

Create order Reduce inventory Take payment Send email Update analytics

Should all five operations happen inside one database transaction?

Probably not.

Payment may involve an external provider.

Email may take seconds.

Analytics should not determine whether an order succeeds.

A better architecture distinguishes between operations that must succeed together and operations that can happen afterward.

For example:

Transaction:
  Create order
  Reserve inventory

After commit:
  Process payment
  Send confirmation
  Record analytics

The exact workflow depends on the product.

The important point is that transaction boundaries express business guarantees.

They answer:

Which changes must be treated as one unit?

That is architectural information.

Architecture should make the safe path easy

Good architecture guides developers toward correct behavior.

Imagine a payment module where any developer can directly write:

database.orders.update({
  status: "paid"
});

The system allows developers to bypass payment verification.

A safer design might provide:

paymentService.confirmPayment(paymentResult);

That operation can enforce:

  • transaction verification,
  • duplicate payment checks,
  • order state validation,
  • audit logging,
  • and notification behavior.

The architecture has reduced the number of ways the system can be used incorrectly.

This principle matters beyond payments.

A strong system makes invalid or dangerous operations harder to perform accidentally.

Architecture should reduce coordination cost

Code is not the only thing architecture organizes.

It organizes people.

Imagine ten developers working in a system where every feature requires changes to one massive app-service.ts.

Even if the code technically works, the architecture creates human problems:

  • merge conflicts,
  • unclear ownership,
  • difficult reviews,
  • accidental regressions,
  • and developers blocking one another.

Clear boundaries allow teams to work more independently.

A billing team can modify billing behavior without understanding every detail of analytics.

A content team can improve publishing without touching authentication.

The ability for teams to change different parts of the system independently is one of architecture's biggest practical benefits.

Do not optimize for imaginary scale

A small application does not automatically need:

microservices
message brokers
CQRS
event sourcing
service meshes
distributed caches
Kubernetes

These technologies solve real problems.

They also introduce real problems.

A microservice may provide independent deployment.

It also introduces:

  • network communication,
  • distributed failure,
  • deployment complexity,
  • service discovery,
  • monitoring requirements,
  • versioning concerns,
  • data consistency problems.

If your actual problem is a website receiving a few thousand requests per day, splitting it into fifteen services is unlikely to make it easier to maintain.

Architecture should address current constraints while leaving reasonable room for growth.

Designing for a hypothetical company with 100 million users can easily make today's ten-user system unnecessarily difficult.

A modular monolith is often enough

For many applications, a well-structured monolith provides an excellent balance.

You can still have clear modules:

app/
  auth/
  customers/
  orders/
  billing/
  products/
  notifications/

Each module can own its responsibilities without requiring separate deployment infrastructure.

If one part eventually needs independent scaling, it can be extracted later.

The key is preserving strong internal boundaries before introducing network boundaries.

A badly structured monolith does not become better simply because it is split into services.

It becomes a distributed badly structured system.

Architecture should be easy to delete

This may sound strange.

But one sign of healthy architecture is that unnecessary components can be removed without destroying the entire application.

Suppose you replace one analytics provider with another.

If analytics calls are scattered across hundreds of files, replacement becomes expensive.

If analytics sits behind a clear boundary, the change remains contained.

The same applies to:

  • email providers,
  • payment gateways,
  • storage systems,
  • search services,
  • caching layers,
  • and third-party APIs.

Good boundaries give you options.

Architecture is partly about preserving the ability to change your mind.

Watch for architectural gravity

Large systems naturally pull unrelated responsibilities toward existing components.

A UserService begins with:

createUser()
updateUser()
getUser()

Then it grows:

sendEmail()
uploadAvatar()
calculateDiscount()
generateInvoice()
trackLogin()
exportCsv()
resetPassword()

Eventually, the class becomes responsible for anything involving a user.

This is architectural gravity.

Existing modules attract new behavior because adding one more method feels easier than deciding where the behavior truly belongs.

A useful question is:

If this feature did not already have this module available, where would I naturally place it?

That question often reveals better boundaries.

Good architecture gives developers clues

When someone receives a feature request, the system should help answer:

Where does this change belong?

If implementing coupon rules clearly leads to:

pricing/discounts/

the architecture is helping.

If the developer must choose between:

helpers/
services/
utils/
common/
processors/

the architecture is making them guess.

Folder names, module boundaries, types, APIs, tests, and naming should all reinforce the same mental model.

This is where architecture overlaps with documentation.

The structure itself should explain the system.

A simple architecture checklist

When evaluating an architecture, ask practical questions.

| Area | Question | | --- | --- | | Change | Can one business change stay mostly local? | | Ownership | Is it clear which module owns this behavior? | | Coupling | Will changing one component unexpectedly break another? | | Dependencies | Are core rules tied unnecessarily to frameworks or vendors? | | Failures | Are failure and retry behaviors explicit? | | Interfaces | Do APIs reveal intent clearly? | | Testing | Can important behavior be tested without excessive setup? | | Operations | Can we understand what happened in production? | | Teamwork | Can developers work independently without constant conflicts? | | Removal | Can infrastructure or providers be replaced without rewriting the system? |

These questions reveal far more than counting architectural layers.

The architecture is the pattern of change

Architecture is not primarily about how code is arranged today.

It is about how that arrangement behaves tomorrow.

Requirements will change.

Business rules will change.

Frameworks will change.

Developers will join and leave.

Services will fail.

Some assumptions will turn out to be wrong.

The purpose of architecture is not to predict every future requirement.

That is impossible.

The purpose is to make change understandable.

Good architecture gives important concepts names.

It places related decisions together.

It creates boundaries where change is likely to diverge.

It isolates unstable infrastructure from stable business rules.

It makes failure visible.

And it gives future developers clues about where the next change belongs.

A beautiful diagram can describe architecture.

But the real test arrives when somebody says:

We need to change how this works.

If the system helps you answer where, why, and how safely that change can happen, the architecture is doing its job.

Occasional notes

A thoughtful email, when there’s something worth sharing.

No noise, no fixed schedule. Just new articles and useful discoveries.

By subscribing, you agree to receive journal updates. Unsubscribe anytime.