Models allow us to reason about the architecture, but they suffer from a fatal flaw. Most models are a representation of the architecture, inherently divorced from the code. Without care, the ideas discussed in our models will never make their way into the code we write. Alas, all our thinking and reasoning about quality attributes is for naught!
Despair not. With some thought, we can build many of our architecture models directly into the code. There are many benefits to building architecture models into the code. When the architecture is self-evident within the code, it’s easier to maintain conceptual design integrity and promote desired quality attributes. Building models into the code decreases the chances of architectural drift since models and code move nearly in lock step. We also alleviate the need for some documentation since we’ve embedded design intent into the software system itself.
Unfortunately, in Just Enough Software Architecture: A Risk-Driven Approach [Fai10], George Fairbanks shows us that it is not possible to directly realize all design concepts from the architecture’s conceptual meta-model in code. It is possible to shrink this model-code gap, but we can’t close it completely.
To shrink the gap between our models and the code, we can use what Fairbanks calls an architecturally evident coding style. With this approach, we embed hints about our models, their rules for use, and the rationale behind the design into the code. Doing this lets us close enough of the gap to make our mental models of the architecture come alive in the code we write.
Terminology mismatch is a common source of confusion when moving from architectural abstractions to code. The architecture talks about layers, services, and filters, but the code implements packages, classes, and methods. The simplest way to embed a model is to use the vocabulary of the architecture.
If we’re using layers, then let’s call our code packages layers. If we’ve adopted a pipe-and-filter pattern, our classes should be named pipes and filters. If we talk about pilots and navigators in our system metaphors, then we should use these words as names for types and instances.
Embedding the domain model into the code is another way to shrink the gap between models and code. Modeling code after domain concepts is a common practice in object-oriented programming. Many frameworks, including object-relational mappers and actor-based systems, assume (or at least strongly encourage) a domain model as part of the implementation. Modeling the domain in this way is a core tenet of domain-driven design and several other design methodologies. Similarly, event-based and reactive patterns lean heavily on insights derived from event models derived from domain workflows.
Good naming is just the beginning. How we organize the code dramatically affects architectural structures in code. A compiler will happily build your Java application whether every class is in the same file or classes are logically organized around thematic packages. The following example shows how to organize layers into their own code packages:
There are other ways to organize this code. Instead of using traditional layers, we could have created functionality-oriented modules. With this pattern, all classes required to complete a functional area are contained within a single package. Classes external to the functional package would not be able to access the business logic or data access classes.
Organizing code so that it matches the designed module structures should be a standard best practice. Patterns on a whiteboard don’t promote quality attributes. The code does. If you can’t see the pattern in the built system, then it doesn’t exist. If the pattern wasn’t implemented, then desired quality attributes can’t be satisfied as designed. Simon Brown has done a lot of work in this area and shares several examples in Software Architecture for Developers [Bro16].
Organizing code into packages that correspond to architectural elements is the least we can do. Even better is to enforce the relations so that it becomes virtually impossible to violate the architecture.
The problem with most architectures is that they rely on discipline and vigilance to maintain conceptual integrity. Instead of relying on discipline alone, look for ways to enforce the architecture in code. It is impossible (or at least really difficult) to disobey design decisions enforced by the code.
The degree to which we can enforce the architecture depends on the type of structures we’re dealing with, the programming languages, operating environment, and the other technical factors.
Module structures are the simplest to see in the code but often the most difficult to enforce. In most modern programming languages, we can enforce an allowed to use relation by limiting access to specific modules. If that fails, it’s usually possible to distribute modules as a library with decent documentation.
When we can’t enforce relations, we can at least monitor them. Use static analysis tools to identify violations of the uses, allowed to use, or requires relations. In some programming languages, you might use types creatively to render relations among elements visible and easy to monitor.
One approach to enforcing component and connector models is to design the system to fail fast when the architecture is violated. Design by contract, first defined in Object-Oriented Software Construction [Mey97], is an approach where pre-conditions, post-conditions, and invariants are added to the code and checked at runtime. When a developer violates the contract, the application throws an error and execution ceases. Contracts work at many granularities of abstraction, including objects, services, and processes across threads.
Another common approach to enforcing C&C models is to prevent connections between components that should not be connected. One example is to require authentication between components, a common practice when connecting to a data source from a data access tier.
The swift rise in popularity of microservices architecture is in part due to the fact that the pattern makes domain models visible and enforceable at runtime. Enforcing allowed to use relations within module structures can be challenging. Turn those modules into components, and we can enforce interaction rules at runtime.
Expressing the intent behind allocation models in the code used to be extremely difficult. It is not possible to describe and enforce allocation models in the code thanks to the rise of technologies and paradigms such as platform-as-a-service, container technologies (for example, Docker), infrastructure as code, and distributed version control systems.
Treating infrastructure as code creates opportunities for static analysis. Automating build and deployment pipelines to take advantage of cloud-based platforms means we can introduce automated architecture checks into the deployment process. Most platform-as-a-service products can test hardware allocation limits. We can also use configuration and automation to enforce hardware scaling and platform provisioning.
Containers are lightweight and disposable compared to physical hardware and traditional virtual machines. With containers, it is possible to adopt simple and easy-to-enforce allocation patterns such as installing one process per container.
Distributed version control combined with web-based tools such as GitHub makes it easy to allocate teams to specific architectural components while maintaining an open, social development culture. Workflows such as fork and pull or upstream repository limit access without preventing collaboration.
Code itself can only take our models so far. We might be able to enforce design decisions, but code constructs won’t tell us why those decisions were made. We can infuse some rationale into the code with good naming and an appropriate use of known patterns. For everything else, there are comments.
Descriptive prose within the code can take many forms. Comments that describe rationale are essential, and you should link liberally to existing design documentation. Even exception messages can contain design hints. We can avoid generic errors by briefly explaining the design rationale behind the error. For example, an UNKOWN error is less helpful than ASSUMPTION_VIOLATED: Document ID required for validation.
Even when models cannot be seen or enforced in the code, sometimes we can automatically generate models of the system we’ve built. Depending on the programming language, technologies, and patterns, it may be possible to use models to verify compliance and monitor design evolution automatically.
Any modern object-oriented language can generate UML class and package diagrams. Most programming languages have a dependency analysis tool. Use this generated models to analyze module structures.
Component and connector structures are harder to generate automatically. To generate C&C structures, add instrumentation so that runtime models can be observed. Use the recorded data to generate models and perform other architecture compliance analysis. See Activity 33, Observe Behavior for further thoughts on this topic.
3.145.74.63