#
Architecture
Formally, Software architecture is the "fundamental organization of a system, embodied in its components, their relationships to each other and the environment, and the principles governing its design and evolution" [IEEE 1471-200].
Less formally, architecture is a shared understanding of how the system is structured. Martin Fowler (2003) attempts to pin down the term in a couple of different ways:
Definition 1: “Expert developers working on a project have a shared understanding of the system design. This shared understanding is called ‘architecture’ and includes how the system is divided into components and how the components interact through interfaces.”
Definition 2: “Architecture is the set of design decisions that must be made early in a project [and that we would like to get right]”.
Architecture is the holistic analysis of a system, and how it's parts relate to one another. Instead of examining requirements in isolation, we instead want to look at the consequences of the structure itself, including the qualities that emerge from this structure.
Architecture can be said to address the intersection of business goals, user goals and technical (system) qualities. The architect needs to determine how to deliver the functional requirements in a way that also addresses these qualities, and other potential business needs (e.g. cost). This may very well include making tradeoff decisions ahead of time. e.g. a user may want a system to return the results of a query in less than 5 seconds, but the cost of doing this might be prohibitively expensive!
The benefit to a careful architecture is that we have a more stable initial design that reflects our project concerns, while still allowing for adaptability, flexibility and other desireable qualities. We'll discuss different qualities of a system below.
So, what qualities do we want in the software that we build? What are the goals of architecture?
#
Qualities
#
1. Usability
A system must be usable for its intended purpose, and meet its design objectives. This includes both functional and non-functional requirements.
- Functional: features that are required to address the problem that the software is intended to solve; desirable features for our users.
- Non-Functional: qualities or characteristics of software that emerge from its structure. e.g., performance, reliability and other quality metrics.
This requires us to carefully ensure that we are meeting all reasonable requirements up-front, typically by collaborating with our users to define problems and solutions. See Software Practices > Process Models.
#
2. Extensibility
You should expect to adapt and modify your software over a lifetime of use:
- You will find errors in your code (aka bugs) that will need to be addressed.
- You might find that requirements were incomplete, so existing features may need to be modified.
- You might uncover new features that need to be implemented.
- Your operating environment might change (e.g. OS update) which necessitates updates.
We need to design systems such that you can respond and adapt your software to these changes, effectively, and with the least amount of work.
Your design goal is not to deliver software once, it's to design in a way that supports delivering frequently and reliably over the life of your solution.
#
3. Scalability
Extensibility refers to handling new or changing requirements. Scalability refers to the ability to handle increased data, number of users, or features. e.g., an online system might initially need to handle hundreds of concurrent users, but could be expected to scale to tends of thousands over time. Scalability is challenging because you don't want to incur the deployment costs up-front, but instead you want to design in a way that lets you expand your system over time. This can include replacing modules with more capable ones at a later time, e.g., swapping out a low-performance database for something more performant.
#
4. Robustness
The system should be able to handle error conditions gracefully, without compromising data. This includes user input errors, errors processing data from external sources, and recovering from error conditions, e.g., power outages.
#
5. Reusability
Software is expensive and time-consuming to produce, so anything that reduces the required cost or time is welcome. Reusability or code reuse is often positioned as the easiest way to accomplish this. Reusing code also reduces defects, since you're presumably reusing code that is tested and known-good.
I see three levels of reuse. At the lowest level, you reuse classes: class libraries, containers, maybe some class "teams" like container/iterator.
Frameworks are at the highest level. They really try to distill your design decisions. They identify the key abstractions for solving a problem, represent them by classes and define relationships between them. JUnit is a small framework, for example. It is the “Hello, World” of frameworks. It has Test, TestCase, TestSuite and relationships defined.
A framework is typically larger-grained than just a single class. Also, you hook into frameworks by subclassing somewhere. They use the so-called Hollywood principle of “don’t call us, we’ll call you.” The framework lets you define your custom behaviour, and it will call you when it’s your turn to do something. Same with JUnit, right? It calls you when it wants to execute a test for you, but the rest happens in the framework.
There also is a middle level. This is where I see patterns. Design patterns are both smaller and more abstract than frameworks. They’re really a description about how a couple of classes can relate to and interact with.
-- Shvets (citing Erich Gamma), Dive Into Design Patterns (2019).
#
Architectural Patterns
An architectural pattern is the high-level structure that describes how components are organized and structured. Similar to design patterns, an architectural pattern is a general solution that has been found to work well at solving specific types of problems.
We typically think of patterns as either monolithic
, or distributed
.
A monolithic pattern is one where the implementation describes a single physical topology (e.g. a self-contained system or application). Many applications that we use are monolithic by design. Note that the ability to utilize remote services doesn't contradict this definition: a monolithic application could still make remote calls to fetch data from a web service, or a database.
A distributed pattern is one where computation is distributed across different systems (e.g. computation is done remotely). These distinct systems need to communicate with one another, and coordinate the distribution and management of work. This is typically done over a network connection.
Different architectures will exhibit different properties. This means that there is no "perfect" architectural pattern! You need to marry your requirements to an architecture that will adequately support them. Ideally, your architecture would also meet the qualities that we identified earlier.
Having said that... we will only talk about a couple of styles, and we'll gravitate towards a pattern that should work well for most applications.
#
Big Ball of Mud
Architects refer to the lack of any discernible architecture structure as a Big Ball of Mud.
A Big Ball of Mud is a haphazardly structured, sprawling, sloppy, duct-tape-and-baling-wire, spaghetti-code jungle. These systems show unmistakable signs of unregulated growth, and repeated, expedient repair. Information is shared promiscuously among distant elements of the system, often to the point where nearly all the important information becomes global or duplicated.
-- Foote & Yoder 1997.
From Building Evolutionary Architectures by Neal Ford, Rebecca Parsons, Patrick Kua
A Big Ball of Mud isn't intentional—it's the result of a system being tightly coupled, where any module can reference any other module. A system like this is extremely difficult to extend or modify.
This obviously won't work. We need something that is extensible and scalable but doesn't sacrifice our ability to design interesting features.
#
Pipeline
A pipeline (or pipes and filters) architecture is appropriate when we want to transform data in a sequential manner. It consists of components linked together in a specific fashion:
Pipes form the communication channel between filters. Each pipe is unidirectional, accepting input on one end, and producing output at the other end.
Filters are entities that perform operation on data that they are fed. Each filter performs a single operation, and they are stateless. There are different types of filters:
- Producer: The outbound starting point (also called a source).
- Transformer: Accepts input, optionally transforms it, and then forwards to a filter (this resembles a map operation).
- Tester: Accepts input, optionally transforms it based on the results of a test, and then forwards to a filter (this resembles a reduce operation).
- Consumer: The termination point, where the data can be saved, displayed etc.
These abstractions may appear familiar, as they are used in shell programming. It's broadly applicable anytime you want to process data sequentially, according to fixed rules. Examples include: photo manipulation software, shells. Pipelines aren't suitable for many current applications which are designed around user-interaction.
#
Event-Driven
An event-driven architecture is designed around the production, transmission and consumption of events between loosely-coupled components.
Unlike a pipeline architecture, which tends to assume linear ordering of consumers, an event-driven architecture expects multiple consumers for a particular event, and works best when ordering isn’t critical. Producers and consumers have no knowledge of one another.
Consumers are also independent and have no knowledge of what each other is doing with an event.
This approach can be useful in systems that generate and handle large volumes of data. It is applicable to both monolithic applications and distributed applications (with restrictions) e.g. any system that manages hardware events or interrupts.
#
Microkernel
A microkernel architecture (aka plugin architecture) is a popular pattern that provides the ability to easily extend application logic to external, pluggable components.
This architecture works by focusing the primary functionality into the core system and providing extensibility through the plugin system. Plugins can be from different developers.
e.g. IntelliJ uses a plugin architecture to allow you to install third-party extensions.
Advantages
- Great flexibility and extensibility.
- Can add plug-ins while the application is running.
- Plug-in modules can be tested in isolation.
- Good portability.
#
Layered
This is the simplest approach we can take: we just split our application logic into a couple of distinct classes/areas of responsibility. This is also known as a layered architecture.
Standard layers in this style of architecture include:
- Presentation: UI layer that the user interacts with.
- Business Logic: the application logic, or "business rules".
- Persistence Layer: describes how to manage and save application data.
- Data Store: the underlying mechanism to actually save data.
The characteristics of this approach:
- Each layer is a module, containing classes with similar functionality.
- There is no limit to the number of layers, although the four illustrated here are common.
- Each layer provides services to the layers above it.
- Dependencies are top-down only. e.g. the UI depends on the underlying business layer, which depends on the persistence layer and so on.
The major characteristic of a monolithic or layered style is that it enforces a clear separation of concerns between layers: the Presentation layer doesn't know anything about the application state or logic, it just displays the information; the Business Logic layer knows how to manipulate the data, but not how it is stored and so on.
Data is passed back up the layers through dependency inversion: the use of interfaces to decouple the lowest layers from the rest of the application.
A layered architecture is an extremely common way to build an application! It falls out of some organizational styles quite naturally (see Conway's Law).
- Front-end developers and designers work on the Presentation (UI) layer.
- Back-end and database developers work on the Persistence layer.
- Full-stack developers integrate these layers.
The layered architecture is similar to Model-View-Controller (MVC), which leverages the Observer design pattern to separate business logic from the user interface. MVC is focused specifically on user-interfaces; we'll revisit it again when we talk about designing UIs.
#
Client-Server
A client-server architecture breaks an application into a client (front-end) and remote server (back-end) which handles data processing and/or storage. This is a logical extension to a layered architecture, where we can move some of the processing (typically the persistance layer) to a different system.
Diagram from Wikipedia.
#
Service
A services-based architecture splits functionality into small "portions of an application”. Each service is independent and separately deployed (i.e. a separate application), and provides specific coarse-grained domain functionality to the clients.
The client application needs to communicate with each service using some lightweight protocol. If data needs to be shared, services can share data via a database.
e.g. a service might handle a customer checkout request to process an order; this could be processed in its entirely by one service, as a single transaction.
#
Microservices
A microservices architecture is a collection of loosely coupled services.
Services are organized around business capabilities i.e. they provide specialized, domain-specific services to applications. However, these services are small, decentralized, and independently deployable. Each micro-service operates independently, but can coordinate tasks with other services if needed.
What makes them different from services? We allow redundant services! This provides scalability e.g., to handle increased demand or component failures.
#
UI Patterns
#
MVC
The most basic UI structure is Model-View-Controller (MVC), which leverages the Observer design pattern to separate business logic from the user interface.
MVC divides any application into three distinct parts:
- Model: the core component of the application that handles state ("business logic layer").
- View: a representation of the application state, often as a user-interface ("presentation layer")
- Controller: a component that accepts input, interprets user actions and converts to commands for the model or view.
Similar to the observer pattern, the views monitor the system state, represented by the model. When the state changes, the views are notified and they update their data to reflect these changes. Notifications frequently happen through events generated by, and managed by, the toolkit that you're using.
Often this is realized as separate classes for each of these components, with an additional main
class to bind everything together.
// main class
class Main {
val model = Model()
val controller = Controller(model)
val view = View(controller, model)
model.addView(view)
}
We use an interface to represent the views, which provides the flexibility to allow many different types of output for the program. Any class can be a view as long as it supports the appropriate method to allow notifications from the model.
There have been many variations on MVC. We'll present a couple of common ones below.
interface IView {
fun update()
}
class View(val controller: Controller, val model: Model): IView {
override fun update() {
// fetch data from model
}
}
The model maintains a list of all views, and notifies them with state changes (indicating that they may wish to refresh their data, or respond to the state change in some way).
class Model {
val views = listOf()
fun addView(view: IView) {
views.add(view)
}
fun update() {
for (view : views) {
view.update()
}
}
}
The controller just passes input from the user to the model.
class Controller(val model: Model) {
fun handle(event: Event) {
// pass event data to model
}
}
One issue with this version of MVC is that the controller often serves little purpose, except to pass along events that are captured by the View (the View contains the user-interface and widgets, and generates events as the user interacts with it).
MVC remains common for simple applications, but tends to be implemented as just a model and one or more views, with the controller code included in the view itself.
#
MVP
Model-View-Presenter (MVP) keeps the key concept in MVC - separating the business logic from the presentation - and introduces an intermediate Presenter which handles converting the model's data into a useful format for the views. This is typically done explicitly by the Presenter class. This variation is also popular when you have multiple views that you wish to control centrally (e.g. subscreens where the Presenter is responsible for determining which screen is active).
MVP arose from Taligent in the 1990s, but was popularized by Martin Fowler around 2006.
#
MVVM
Model-View-ViewModel was invented by Ken Cooper and Ted Peters to simplify event-driven programming of user interfaces in C#/.NET. It's similar to MVP, but includes the notion of binding variables to widgets within the framework, so that changes in widget state are are automatically propagated from the view to other components.
MVVM includes the following components:
- Model: as MVC, the core component that handles state. It can also map to a data access layer or database directly.
- View: a representation of the application state, presented to the user.
- ViewModel: a model that specifically interprets the underlying Model state for the particular view to which it is associated. Typically we rely on binding to map variables in the ViewModel directly to widgets in the View, so that updating one directly updates the other.
MVVM is common in modern languages and toolkits and has the advantage of replacing all "mapping" code with direct binding of variables and widgets by the toolkit. This greatly simplifies interface development.
There are many other variants (e.g. Model-View-ViewController) which deviate from "standard" MVC, usually in an attempt to solve a particular problem in a more elegant fashion. They all build on the same observer
foundation.
MVC and it's variants are really meant to accomplish two things:
- Enforce separation of concerns. "Business logic" or application data is stored in the model, and the view can make decisions on how to present that data, and how the user should interact with it.
- Handle state management. It provides a strict division of state, so that the model is meant to contain the "source of truth", and it establishes rules on how state should be changed and propagated through your application.
However, these are difficult questions to address, and MVC designs that appear reasonable, will often feel inelegant as applications grow.
#
Clean Architecture
Layering our architecture really helps to address our earlier goals (reducing coupling, setting the right level of abstraction). Additionally, it provides these other specific benefits:
- Independent of frameworks. The architecture does not depend on a particular set of libraries for its functionality. This allows you to use such frameworks as tools, rather than forcing you to cram your system into their limited constraints.
- Testable. The business rules can be tested without the UI, database, web server, or any other external element.
- Independent of the UI. The UI can change easily, without changing the rest of the system. A web UI could be replaced with a console UI, for example, without changing the business rules.
- Independent of the database. You can swap out Oracle or SQL Server for Mongo, BigTable, CouchDB, or something else. Your business rules are not bound to the database.
So what's wrong with using a layered architecture? It's too specific, and assumes that we are ultimately pushing data between a top-level UI layer and a bottom-level persistence layer. Modern software requires more flexibility.
Clean architecture is an alternative style commonly used with modern mobile and desktop applications, which generalizes a layered architectural model. It is also a domain-driven design that focuses on use-cases and addressing user needs, and aligns well with User-Centred Design (UCD).
Clean Architecture is derived from other modern approaches to architecting systems (described in Martin 2017). These include:
- Hexagonal Architecture, developed by Alistair Cockburn, and adopted by Steve Freeman
- DCI from James Coplien and Trygve Reenskaug
- BCE, introduced by Ivar Jacobson
This diagram represents different layers of software, each with their own responsibilities. The following guidelines help determine how layers interrelate.
#
Dependency Rule
The dependency rule describes relationship between layers: nothing in the inner circle can know about the outer circles. Dependencies are only allowed to go from the outside to the inside; outside classes can only refer to the same or a more inner layer.
This means that the domain objects (innermost layer) don't directly reference or use any external frameworks (outermost layer). External frameworks and libraries are pushed to the outside of the architecture to reduce coupling to these frameworks! This allows for greater flexibility later, if we need to replace one of them, e.g., switching to a different database implementation.
#
Layers
A clean architecture is like an onion: layers spread out from the core entities. Here's what each layer does.
#
Entities (Domain Models)
These are data classes that reflect your problem domain, e.g. classes like Customer, Invoice, Journal, Note. These are the core classes in your application and unlikely to change from external pressures or from the addition of features (which is why we can have everything else depend on them!)
What differentiates these from use cases is that enterprise entities can span components or services. If you are building a single application, then these are the core data classes.
#
Use Cases (Application)
These are application-specific classes that build on the core entities. They facilitate data flow to and from the entities. This is also where you would implement core application logic (that wasn't handled by the outermost layers like the UI framework).
#
Controllers (Infrastructure)
These are interface adapters that map data from the entities or use cases, to a format that is required by the external layers. For instance, this layer might add the model-view-controller abstraction for the user interface, or convert data from classes into a format that is suitable for storing in a database.
#
Interfaces (Frameworks)
These are frameworks that provide services. Typically, these services are provided for you and you merely need to interface with them.
This design addresses our earlier concerns by providing a separation of concerns between each layer. This allows us to swap databases, user interfaces and other layers as needed.
#
Dependency Inversion
The diagram above shows flow-of-control: which classes call into which other classes. In this case, outside classes call into inner classes. This also represents the source code dependencies.
The way we handle source code dependencies is with dependency inversion. This is meant to keep maintain the distance between high and low level modules:
- High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).
- Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
#
Source Code
How do we accomplish this in code?
- We set up packages to separate classes into modules.
- We use interfaces to describe the relationship between classes.
For example, we would set up a source code structure like this, where each subdirectory is a package.
project
├── application
├── database
├── entities
├── presentation
└── service
We will see examples of using this architecture when setting up our edge dependencies, e.g., database, user interface.
#
Why do we do this?
- It reduces coupling by carefully controlling dependencies.
- It encourages cohesion since layers have a specific responsibility.
- External dependencies are pushed to the outermost layers, which reduces dependencies on specific frameworks or specific implementation dependendies. e.g., it is easier to swap out user interface frameworks, or keep your application decoupled from any specific implementation.
- Reducing dependencies makes it possible to test classes at each layer independently! We'll see this when we discuss unit testing.
In some ways, a clean architecture can also be described as a generalized layered architecture. We'll use these diagrams interchagably as we build out some of our other systems.
#
One Style...
How do we reconcile the Clean Architecture model with MVC and it's variants? If you put them side-by-side, you can see that there are similarities in the way they are structured.
Both models are an attempt to force dependencies down towards the models or entities. In other words, the external layers This helps to ensure that the business logic is separated from the UI, and that the UI is as simple as possible. This is essential for control-flow, or flow of input through the application.
Control-flow: The path through which individual instructions are executed or evaluated. In an event-driven architecture, the path that events flow through the system. Control flows down from the UI, or service to the models.
Data-flow: The path through which data flows through the system, typically from some data source to a user-viewable output. Data flows up from the models to the UI.
See the Clean UI
sample posted alongside the course slides. This is a simple example of how you might structure a UI in a Clean Architecture style.
classDiagram View "1" ..> "1" UserController UserController "*" ..> "1" UserModel ISubscriber "1" <|.. "1" UserViewModel IPublisher <|.. UserModel ISubscriber "*" <.. "*" IPublisher View "1" <-- "1" UserViewModel UserViewModel "*" <-- "*" UserModel class View { -UserController controller -UserViewModel viewModel } class ISubscriber { <<Interface>> +update() } class UserViewModel { -View view -UserModel model +update() } class UserController { +UserModel model +invoke(Event) } class IPublisher { <<Interface>> -List~Subscriber~ subscribers +notify() } class UserModel { -String firstname -String lastname +subscribe(ISubscriber) +unsubscribe(ISubscriber) }