Creating evolvable architectures

Requirements volatility—changing customer demands—is an unavoidable challenge for software projects. Product requirements and context will change over time; your application must change as well. But changing requirements can cause instability and derail development.

Managers try to deal with requirements volatility using iterative development processes like Agile development.

You can do your part to accommodate changing requirements by building evolvable architectures. Evolvable architectures eschew (avoid) complexity, the enemy of evolvability. But achieving simplicity in software can be difficult; without conscious effort, code will grow tangled and complex.

Understanding Complexity

“Complexity is anything related to the structure of a system that makes it hard to understand and modify the system.” - Philosophy of Software Design (Yaknyam Press, 2018), Stanford computer science professor John Ousterhout

Per Ousterhout, complex systems have two characteristics: high dependency and high obscurity. We add a third: high inertia.

  • High dependency leads software to rely on other code’s API or behavior. Dependency is obviously unavoidable and even desirable, but a balance must be struck. High-dependency systems are hard to modify because they have tight coupling and high change amplification.

    • Tight coupling describes modules that depend heavily on one another.

    • It leads to high change amplification, where a single change requires modifications in dependencies as well.

    • Thoughtful API design and a restrained use of abstraction will minimize tight coupling and change amplification.

  • High obscurity makes it difficult for programmers to predict a change’s side effects, how code behaves, and where changes need to be made.

    • Obscure code takes longer to learn, and developers are more likely to inadvertently break things.

    • God objects that “know” too much, global state that encourages side effects, excessive indirection that obscures code, and action at distance that affects behavior in distant parts of the program are all symptoms of high obscurity.

    • APIs with clear contracts and standard patterns reduce obscurity.

  • Inertia, the characteristic that we’ve added to Ousterhout’s list, is software’s tendency to stay in use.

    • Easily discarded code used for a quick experiment has low inertia.

    • A service that powers a dozen business- critical applications has high inertia.

    • Complexity’s cost accrues over time, so high-inertia, high-change systems should be simplified, while low-inertia or low-change systems can be left complex (as long as you discard them or continue to leave them alone).

Complexity cannot always be eliminated, but you can choose where to put it.

  • Backward-compatible changes (discussed later) might make code simpler to use but more complicated to implement.

  • Layers of indirection to decouple subsystems reduce dependency but increase obscurity.

  • Be thoughtful about when, where, and how to manage complexity.

Design for Evolvability

Faced with unknown future requirements, engineers usually choose one of two tactics:

  • they try to guess what they’ll need in the future, or

  • they build abstractions as an escape hatch to make subsequent code changes easier.

Don’t play this game; both approaches lead to complexity.

  • Keep things simple (known as KISS—keep it simple, stupid).

  • Use the KISS mnemonic to remember to build with simplicity in mind.

  • Simple code lets you add complexity later, when the need becomes clear and the change becomes unavoidable.

  • The easiest way to keep code simple is to avoid writing it altogether.

Use these tips to manage complexity:

You Ain’t Gonna Need It: don’t build things you don’t need.

  • YAGNI violations happen when developers get excited, fixated, or worried about some aspect of their code.

  • It’s difficult to predict what you’ll need and what you won’t.

  • Every wrong guess is wasted effort.

  • After the initial waste, the code continues to bog things down, it needs to be maintained, developers need to understand it, and it must be built and tested.

  • Luckily, there are a few habits you can build to avoid unnecessary development.

    • Premature optimization occurs when a developer adds a performance optimization to code before it’s proven to be needed. After shipping the code, the developer discovers that the optimization was not needed. Removing optimization never happens, and complexity accrues. Most performance and scalability improvements come with a high complexity cost.

    • Flexible abstractions—plugin architectures, wrapper interfaces, and generic data structures like key-value pairs—are another temptation. Developers think they can easily adjust if some new requirement pops up. But abstraction comes with a cost; it boxes implementations into rigid boundaries that the developer ends up fighting later. Flexibility also makes code harder to read and understand.

    • The best way to keep your code flexible is to simply have less of it. For everything you build, ask yourself what is absolutely necessary, and throw away the rest. This technique—called Muntzing—will keep your software trim and adaptable.

    • Adding cool new product features is also tempting. Developers talk themselves into the cool-feature pitfall for a variety of reasons: they mistake their usage for what most users want, they think it’ll be easy to add, or they think it’ll be neat! Each new feature takes time to build and maintain, and you don’t know if the feature will actually be useful.

  • Building an MVP will keep you honest about what you really need. MVPs allow you to test an idea without investing in a full-fledged implementation.

  • There are, of course, caveats to YAGNI. As your experience grows, you’ll get better at predicting when flexibility and optimization are necessary. In the meantime, place interface shims where you suspect optimizations can be inserted, but don’t actually implement them.

Principle of Least Astonishment: The principle of least astonishment is pretty clear:

  • don’t surprise users. Build features that behave as users first expect. Features with a high learning curve or strange behavior frustrate users.

  • Similarly, don’t surprise developers. Surprising code is obscure, which causes complexity. Avoid implicit knowledge and use standard libraries and patterns.

    • Anything nonobvious that a developer needs to know to use an API and is not part of the API itself is considered implicit knowledge. Two common implicit knowledge violations are:

      • Ordering requirements dictate that actions take place in a specific sequence. Method ordering is a frequent violation: method A must be called before method B, but the API allows method B to be called first, surprising the developer with a runtime error. Documenting an ordering requirement is good, but it’s better not to have one in the first place. Counterintuitively, short method and variable names actually increase cognitive load. Specific, longer method names are more descriptive and easier to understand.

      • Hidden argument requirements occur when a method signature implies a wider range of valid inputs than the method actually accepts. For example, accepting an int while only allowing numbers 1 to 10 is a hidden constraint. Requiring that a certain value field is set in a plain JSON object is also requiring implicit knowledge on the part of the user.

    • use standard libraries and development patterns. Implementing your own square root method is surprising; using a language’s built-in sqrt() method is not. The same rule applies for development patterns: use idiomatic code style and development patterns.

Encapsulate Domain Knowledge: Software changes as business requirements change.

  • Encapsulate domain knowledge by grouping software based on business domain—accounting, billing, shipping, and so on. Mapping software components to business domains will keep code changes focused and clean.

  • Encapsulated domains naturally gravitate toward high cohesion and low coupling—desirable traits.

  • Highly cohesive software with low coupling is more evolvable because changes tend to have a smaller “blast radius.”

  • Code is highly cohesive when methods, variables, and classes that relate to one another are near each other in modules or packages.

  • Decoupled code is self-contained; a change to its logic does not require changes to other software components.

  • Developers often think about software in terms of layers: frontend, middle tier, and backend.

    • Layered code is grouped according to technical domain, with all the UI code in one place and all object persistence in another.

    • Grouping code by technical domain works great within a single business domain but grows messy as businesses grow.

    • Separate teams form around each tier, increasing coordination cost since every business logic change slices through all tiers. And shared horizontal layers make it too easy for developers to mix business logic between domains, leading to complex code.

  • Domain-Driven Design (DDD), which defines an extensive set of concepts and practices to map business concepts to software.

Evolvable APIs

Note

API - the shared interfaces between code.

As requirements change, you’ll need to change your APIs, the shared interfaces between code. Changing an API is easy to do, but it’s hard to do right. Many small, rational changes can lead to a sprawling mess. Worse, a minor API change can completely break compatibility. If an API changes in an incompatible way, clients will break;

Use these tips to keep APIs evolvable.

Keep APIs Small: Apply the YAGNI philosophy: only add API methods or fields that are immediately needed. API methods with a lot of fields should have sensible defaults.

Expose Well-Defined Service APIs:

  • Evolvable systems have clearly defined request and response schemas that are versioned and have clear compatibility contracts.

  • Schema definitions should be published so they can be used to automatically test both client and server code.

  • Use standard tools to define service APIs. A well-defined service will declare its schemas, request and response methods, and exceptions.

    • OpenAPI is commonly used for RESTful services,

    • while non-REST services use Protocol Buffers, Thrift, or a similar interface definition language (IDL).

  • Well-defined service APIs make compile-time validation easier and keep clients, servers, and documentation in sync.

  • Interface definition tools come with code generators that convert service definitions to client and server code.

  • Documentation can also be generated, and test tools can use IDLs to generate stubs and mock data.

  • Some tools even have discoverability features to find services, learn who maintains them, and show how the service is used.

Keep API Changes Compatible: Keeping API changes compatible lets client and server versions evolve independently. There are two forms of compatibility to consider: forward and backward.

  • Forward-compatible changes allow clients to use a new version of an API when invoking an older service version. A web service that’s running version 1.0 of an API but can receive calls from a client using version 1.1 of the API is forward compatible.

  • Backward-compatible changes are the opposite: new versions of the library or service do not require changes in older client code. A change is backward compatible if code developed against version 1.0 of an API continues to compile and run when used with version 1.1.

  • When client and server content expectations diverge, errors crop up no matter what format you’re using. Moreover, it’s not just the message fields you need to worry about: a change in semantics of the message, or the logic of what happens when certain events transpire, can also be backward or forward incompatible.

Version APIs:

  • As APIs evolve over time, you will need to decide how to handle compatibility across multiple versions.

  • Fully backward-and forward-compatible changes interoperate with all previous and future versions of an API; this can be hard to maintain, creating cruft like the logic for dealing with deprecated fields.

  • Less stringent compatibility guarantees allow for more radical changes.

  • Versioning your APIs means you introduce a new version when changes are made.

    • Old clients can continue using the old API version.

    • Tracking versions also helps you communicate with your customers—they can tell you what version they’re using, and you can market new features with a new version.

  • API versions are usually managed with an API gateway or a service mesh.

    • Versioned requests are routed to the appropriate service: a v2 request will be routed to a v2.X.X service instance, while a v3 request will be routed to a v3.X.X service instance.

    • Absent a gateway, clients invoke RPC calls directly to version-specific service hosts, or a single service instance runs multiple versions internally.

  • API versioning comes with a cost. Older major versions of the service need to be maintained, and bug fixes need to be backported to prior versions.

    • Developers need to keep track of which versions support which features.

    • Lack of version management tooling can push version management on to engineers.

  • Semantic versioning, discussed in Chapter 5, is a common API versioning scheme, but many companies version APIs using dates or other numeric schemes.

  • Keep documentation versioned along with your APIs.

  • API versioning is most valuable when client code is hard to change. You’ll usually have the least control over external (customer) clients, so customer-facing APIs are the most important to version.

  • If your team controls both the service and client, you might be able to get away without internal API versioning.

Evolvable Data

APIs are more ephemeral than persisted data; once the client and server APIs are upgraded, the work is done. Data must be evolved as applications change.

Data evolution runs the gamut from simple schema changes such as adding or removing a column to rewriting records with new schemas, fixing corruption, rewriting to match new business logic, and massive migrations from one database to another.

Note

Semantics - the meaning of the data

Use these tips to keep your data evolvable.

Isolate Databases:

  • Shared databases are difficult to evolve and will result in a loss of autonomy—a developer’s or team’s ability to make independent changes to the system.

    • You will not be able to safely modify schemas, or even read and write, without worrying about how everyone is using your database.

    • The architecture grows brittle as schemas become an unofficial, deeply entrenched API.

    • Application data is not protected, so other applications might mutate it in unexpected ways. Schemas aren’t isolated; a change in one application’s schema can impact others.

    • Nor is performance isolated, so if an application overwhelms the database, all other applications will be affected. In some cases, security boundaries might be violated.

  • Separate application databases make changes easier.

  • Isolated databases are accessed by only a single application, while shared databases are accessed by more than one.

  • There are occasional cases where a shared database is valuable.

    • When breaking a monolith up, sharing a database serves as a useful intermediate step before data has been migrated to a new isolated database. Managing many databases comes at a high operational cost. Early on, it might make sense to co-locate many databases on the same machines.

    • But make sure any shared databases eventually get isolated and split up or replaced.

Use Schemas:

  • Rigid predefined columns and types, and the heavyweight processes for evolving them, have led to the emergence of popular schemaless data management.

    • Schemaless doesn’t literally mean “no schema” (data would be unusable); rather, schemaless data has an implicit schema that is supplied or inferred at read time.

    • In practice, we’ve found that a schemaless approach has significant data integrity and complexity problems.

    • A strongly typed schema-forward approach decreases the obscurity, and therefore complexity, of your application.

    • The short-term simplicity is not usually worth the obscurity trade-off.

    • Like code itself, data is sometimes described as “write once, read many”; use schemas to make reads easier.

    • You’d think not having a schema would make a change easier: you simply start or stop writing fields as you need to evolve data.

    • Schemaless data actually makes changes harder because you don’t know what you’re breaking as you evolve your data.

    • Data quickly becomes an unintelligible hodgepodge of different record types.

  • Defining explicit schemas for your data will keep your application stable and make your data usable.

    • Explicit schemas let you sanity-check data as it is written.

    • Parsing data using explicit schemas is usually faster, too.

    • Schemas also help you detect when forward- and backward- incompatible changes are made.

    • The rigidity of explicit schemas also carries a cost: they can be difficult to change. This is by design. Schemas force you to slow down and think through how existing data is going to be migrated and how down- stream users will be affected.

  • Don’t hide schemaless data inside schematized data. It’s tempting to be lazy and stuff a JSON string into a field called “data” or define a map of strings to contain arbitrary key-value pairs.

    • Hiding schemaless data is self-defeating; you get all of the pain of explicit schemas but none of the gain.

  • There are some cases where a schemaless approach is warranted.

    • If your primary goal is to move fast—perhaps before you know what you need, when you are iterating rapidly, or when old data has little to no value—a schemaless approach lets you cut corners.

    • Some data is legitimately nonuniform; some records have certain fields that others don’t.

    • Flipping data from explicit to implicit schema is also a helpful trick when migrating data; you might temporarily make data schemaless to ease the transition to a new explicit schema.

Automate Schema Migrations:

  • Managing database changes by manually executing database description language (DDL) commands directly on the database is error prone.

    • Database schemas in different environments diverge, the state of the database is uncertain, no one knows who changed what when, and performance impacts are unclear.

    • A minor tweak—adding an index or dropping a column—can cause the entire database or application to grind to a halt.

    • The mix of error-prone changes and the potential for major downtime is an explosive combination.

  • Database schema management tools make database changes less error prone.

    • Automated tooling does two things for you: it forces you to track the entire history of a schema, and it gives you tools to migrate a schema from one version to another.

    • Track schema changes, use automated database tools, and work with your database team to manage schema evolution.

  • Don’t couple database and application lifecycles.

    • Tying schema migrations to application deployment is dangerous.

    • Schema changes are delicate and can have serious performance implications.

    • Separating database migrations from application deployment lets you control when schema changes go out.

  • Most migration tools support rollbacks, which undo a migration’s changes. Rollbacks can only do so much, so be careful.

    • For example, rolling back a column deletion will recreate a column, but it will not recreate the data that used to be stored in that column!

    • Backing up a table prior to deletion is prudent.

Maintain Schema Compatibility:

  • Data written to disk has the same compatibility problems that APIs have.

    • Like APIs, the reader and writer of the data can change independently; they might not be the same software and might not be on the same machine.

    • And like APIs, data has a schema with field names and types.

    • Changing schemas in a forward- or backward-incompatible way can break applications.

  • Even if a production database is hidden behind an application, the data is often exported to data warehouses.

    • Data warehouses are databases used for analytic and reporting purposes.

    • Organizations set up an extract, transform, load (ETL) data pipeline that extracts data from production databases and transforms and loads it into a data warehouse.

    • ETL pipelines depend heavily on database schemas. Simply dropping the column in a production database could cause the entire data pipeline to grind to a halt.

    • Even if dropping a column doesn’t break the data pipeline, downstream users might be using the field for reporting, machine learning models, or ad hoc queries.

  • Other systems might also depend on your database schemas.

    • Change data capture (CDC) is an event-based architecture that converts insert, update, and delete operations into messages for downstream consumers. An insert into a “members” table might trigger a message that an email service uses to send an email to the new user.

    • Such messages are an implicit API, and making backward-incompatible schema changes can break other services.

  • Data warehouse pipelines and downstream users must be protected from breaking schema changes.

    • Validate that your schema changes are safe before executing them in production.

    • Compatibility checks should be done as early as possible, ideally at code commit time by inspecting database DDL statements.

    • Executing DDL statements in a preproduction integration testing environment, if one exists, can also protect changes.

    • Run your DDL statements and integration tests to verify that downstream systems don’t break.

  • You can also protect internal schemas by exporting a data product that explicitly decouples internal schemas from downstream users.

    • Data products map internal schemas to separate user-facing schemas; the development team owns both the production database and the published data products.

    • Separate data products, which might simply be database views, allow teams to maintain compatibility with data consumers without having to freeze their internal database schemas.

Note

Sources:

  1. The Missing README: A Guide for the New Software Engineer © 2021 by Chris Riccomini and Dmitriy Ryaboy, Chapter 11.