Architecture as Code With C4 and Plantuml

I'm lucky enough to currently work on a large microservices-based project as a solution architect. I'm responsible for designing different architecture views, each targeting very different audiences, hence different preoccupations:

NOTE

We use this Open Source Template to document our architecture.

Our current project architecture is fairly complex because of the number of modules (tens of jobs, API, and GUI modules), because of the large number of external partners, and because of its integration with a large legacy information system.

At this time, we have to maintain more than one hundred architecture diagrams. Following a living documentation approach, we adapt and augment diagrams, text, and tables several times a day. As we will see later, it's often a collaborative process taking advantage of several great tools.

The Sample Application

We illustrate this article with a fictional AllMyData microservices application. This is a .gov web application enabling any company to get all its information known to all the public administrations.

We can split our feature "Deliver Companies Data" into two main call chains:

The C4 Model

We use the C4 model to represent our architecture. It is beyond the scope of this tooling article to describe it in depth, but I invite you to have a look at this very pragmatic approach. I find it very natural to design complex architectures. It leverages the UML2 standard and provides a great dichotomy between high-level concerns and code-level ones.

NOTE

Archimate could be another good fit for us but probably overkill in our context of very low modelization adoption and knowledge. Also, we like the C4 KISS/low-tech approach that takes many human psychological criteria into account. Note that some Archimate tools support C4 diagrams using some mapping between concepts. Not sure it is a good idea to mix both, though.

In our context, we currently use three main C4 diagrams types (note that C4 and UML2 contain others not listed here):

NOTE

I'm quite reluctant to use the C4 container term because of the risk of confusion with Docker/OCI containers (as pointed out by Simon Brown, the C4 creator). In our organization, we prefer to call them deployable units. The C4 model encourages terminology adaptation. A C4 container is basically a separate deployable process. The C4 documentation states: "Essentially, a container is a separately runnable/deployable unit (e.g. a separate process space) that executes code or stores data".

In the C4 model, a container can contain one or more software components. This concept doesn't refer to infrastructure components but to some large pieces of code (like a set of Java classes). We barely use C4 components in our architecture document because we don't really need to go into that level of detail (our hexagonal architecture makes things simple to design and understand just by reading the code, and our agile approach makes us prefer limiting the design documentation we have to maintain).

Plantuml

Plantuml is an impressive tool that generates instantly diagrams from a very simple textual DSL (Domain Specific Language).

For instance, this very short text:

Plain Text
 
@startuml
   [Browser] -> [API Foo]: HTTPS
@enduml

...is enough to produce this diagram:


Plantuml comes with hundreds of features and syntax goodies, sometimes undocumented and evolving very quickly. I suggest this website as a clear and exhaustive documentary reference.

Check out some real-world examples here.

Plantuml Combined With C4

Plantuml component diagrams can be customized as C4 diagrams using this extension library.

Just import it at the top of your Plantuml diagrams and use C4 macros:

Plain Text
 
@startuml
   !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
   !include <tupadr3/devicons2/chrome>
   !include <tupadr3/devicons2/java>
   !include <tupadr3/devicons2/postgresql>
   LAYOUT_LEFT_RIGHT()
   Container(browser, "Browser","Firefox or Chrome", $sprite="chrome")
   Container(api_a, "API A","Spring Boot", $sprite="java")
   ContainerDb(db_a, "Database A","Postgresql", $sprite="postgresql")
   Rel(browser,api_a,"HTTPS")
   Rel_R(api_a,db_a,"pg")
@enduml

is exported as:

NOTE

Diagrams Factorization

A great thing about Plantuml is the factorization capabilities using the !include and !includesub preprocessor directives.

It is possible to include local or remote diagrams (ie. starting with @startuml and ending with the @enduml directive). For instance, C4 macros are included using this instruction:

Plain Text
 
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml


More interestingly, it is also possible to import diagram fragments (ie. starting with !startsub and ending with the !endsub directive):

File fragments.iuml:

Plain Text
 
!startsub dmz
  !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
  !include <tupadr3/devicons2/chrome>
  !include <tupadr3/devicons2/java>
  Container(browser, "Browser","Firefox or Chrome", $sprite="chrome")
  Container(api_a, "API A","Spring Boot", $sprite="java")
!endsub

!startsub intranet
  !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
  !include <tupadr3/devicons2/postgresql>
  ContainerDb(db_a, "Database A","Postgresql", $sprite="postgresql")
}
!endsub

!startsub extranet
  !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
  !include <tupadr3/devicons2/postgresql>
  ContainerDb(db_b, "Database B","Postgresql", $sprite="postgresql")
}
!endsub


File diags-1.puml:

Plain Text
 
@startuml use-case-1
  ' We only include context-related sub-diagams
  !includesub fragments.iuml!dmz
  !includesub fragments.iuml!intranet
    
  Rel(browser,api_a,"HTTPS")
  Rel_R(api_a,db_a,"pg")
@enduml


Filtering Unlinked Containers

Since mid-2020, Plantuml has supported a game-changing feature for software architects: the remove @unlinked directive. It only keeps from a C4 diagram the containers calling or being called and drop any other.

This feature (along with the diagram fragments' capacities) was a requirement to achieve the diagram patterns described below.

Sprites

Thousands of sprites are available to decorate the C4 containers. They are now embedded directly into the last Plantuml releases. They include Devicons, Font-Awesome, Material, Office, Weather, and many other icon libraries. Most software, hardware, network, and business-oriented icons are ready to use out of the box!

From my experience, using sprites inside C4 containers makes the diagrams airier and thus, more pleasant to read. Maybe it helps our brain to identify faster the nature of each container.

Note that even if you can use different background colors to differentiate C4 containers based on specific criteria (for instance, I use a light grey for external APIs), we recommend using sprites instead to represent nature, as it makes cleaner diagrams, and the default blue color is fine in most of the cases.

Plantuml IDE Plugins

Plantuml is a very versatile technology that can be used in many different contexts, including:

VSCode Plantuml plugin

Architecture as Code

A very nice side-effect of the IDE Plantuml integration is the fact that you can not only create diagrams much faster by being released from the arrangement chore but also write them as you code. Diagrams can be automatically generated and refreshed as you type.

Mob Designing

This kind of tooling enables what I would call Mob design. Especially at the beginning of our project but still, currently, we used to brainstorm about the software architecture. Using Plantuml and a large shared screen, it is very convenient to create and compare several architecture scenarios.

"What if the API A is called directly by the client B?" Or "Should it be called asynchronously by the job J?" ...

In the same manner that end-users truly need to visualize screen mockups, developers and architects think better in front of diagrams. This also greatly limits misunderstandings induced by the limitation and numerous ambiguities of natural languages.

Inventory and Dependencies Diagrams

As a blueprint, we use the !include and/or !includesub directives to separate:

Example of an inventory diagram:

File inventory.puml:

Plain Text
 
@startuml
header Inventory diagram
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
!include <tupadr3/devicons2/chrome>
!include <tupadr3/devicons2/java>
!include <tupadr3/devicons2/postgresql>
!include <tupadr3/devicons2/nginx_original>
!include <tupadr3/devicons2/react_original>
!include <tupadr3/devicons2/android>
!include <tupadr3/devicons2/groovy>
!include <tupadr3/material/queue>
!include <tupadr3/material/mail>
!include <tupadr3/devicons2/dot_net_wordmark>
!include <tupadr3/devicons2/oracle_original>
!include <office/Concepts/web_services>
skinparam linetype polyline
HIDE_STEREOTYPE()
SHOW_PERSON_PORTRAIT()

System(client, "Client") {
    Container(spa, "SPA allmydata-gui", "Container: javascript, React.js", "Graphical interface for requesting information", $sprite="react_original")
    Container(mobile, "AllMyData mobile application", "Container: Android", "Graphical interface allowing to request information", $sprite="android")
}    

Enterprise_Boundary(organisation, "System organisation B") {
    Container_Ext(saccounting, "Accounting system", "REST service", $sprite="web_services")
}

Enterprise_Boundary(si, "Information System") {
    Container(static_resources, "allmydata-gui Web Application", "Container: nginx", "Delivers static resources (js, html, images ...)", $sprite="nginx_original")
    Container(sm, "allmydata-api", "Container: Tomcat, Spring Boot", "REST service allowing to request information", $sprite="java")
    Container(crep, "Companies repository", "Container", "SOAP webservice providing data about companies known by administration A", $sprite="dot_net_wordmark")
    ContainerDb(crep_db, "companies-repository-db", "Container: SqlServer", "Stores companies data",$sprite="oracle_original")
    Container(batch, "allmydata-batch", "Container: groovy", "Process requests, launched by cron every minute", $sprite="groovy")
    ContainerQueue(queue, "requests-queue", "Container: RabbitMQ", "Stores requests", $sprite="queue")
    ContainerDb(amd_db, "allmydata-db", "Container: PostgreSQL", "Stores requests history and status",$sprite="postgresql")
    Container(sreporting, "service-reporting-pdf", "Container: Tomcat, JasperReport", "Reporting REST service", $sprite="java")
    Container(smails, "mail server", "Container: Postfix", "Send emails", $sprite="mail")
}
@enduml


Example of dependency diagram (importing its inventory counterpart and adding a person and a bunch of calls):

File dependencies.puml:

Plain Text
 
@startuml dependencies
  header Dependencies diagram

  !include inventory.puml
  
  Rel(client, static_resources, "HTTPS")
  Rel(spa,sm,"REST call","HTTPS")
  Rel(sm,queue,"AMQP")
  Rel(sm,amd_db,"psql")
  Rel(batch, queue, "AMQP")
  Rel_R(batch, saccounting, "HTTPS")
  Rel(batch, sreporting,"HTTP")
  Rel(batch, smails, "SMTP")

  remove @unlinked
@enduml


Dynamic Diagrams to Describe Call Chains

Once we have provided the system big picture using both an inventory and dependencies view, we describe the detailed architecture of each main feature using a third kind of C4 diagram: C4 dynamic diagrams. C4 container and dynamic diagrams are very similar but the latter comes with automatic call numeration.

NOTE

C4 dynamic diagrams target developers. They detail calls or data streams between C4 containers involved in the context of a given feature, hence providing a detailed view of each call chain.

The feature term should be intended in the agile meaning (fulfills a stakeholder need). It can be something like "Allow an enterprise to access its data online" or "Pay for an order".

This kind of diagram can still contain zones or boundaries (already available in the inventory or dependencies diagrams), thus setting up the call chain in a more global context.

The architecture leverages one or more call chains, and a call chain is made of a group of ordered calls or actions (like calling an API, writing a file on disk, etc.) all performed synchronously. Any further call is referenced in the next call chain.

NOTE

We leverage the inventory diagrams fragments and unlinked container filtering explained before to achieve an effective Architecture As Code pattern.

File call chain deliver-1.puml (note the remove @unlinked usage here):

Plain Text
 
@startuml deliver-1.puml
  !include inventory.puml
  !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Dynamic.puml
  
  ' For call chains, we advise to put a header (displayed by default at the upper-right 
  ' side of the diagram) to ease its identification.
  header deliver-1

  Person_Ext(company, "Company", "[person] \nWeb client (PC, tablet, mobile)")
  Rel(client, static_resources, "Visit https://allmydata.gouv", "HTTPS (R)")
  Rel(client, spa, "Retrieves information via")
  Rel(spa,sm,"REST call","HTTPS (W)")
  RelIndex(LastIndex()-1,sm,queue,"Produces a request message to the queue","AMQP (W)")
  RelIndex(LastIndex()-2,sm,amd_db,"Stores the request data","JDBC (W)")
  increment()
  
  ' Remove all C4 containers imported from inventory.puml file but not involved 
  ' in this call chain to make the diagram much cleaner
  remove @unlinked
@enduml


NOTE

It is paramount to standardize call chain naming (like deliver-1, pay-3, ...) because it becomes a strong vector of communication between developers and business analysts. It is then possible to talk using canonical names like deliver-1 3-1, for instance. This is a massive misunderstanding killer and time saver and is one of the main benefits of this methodology.

I suggest simply using the <feature>-<incrementing number> naming scheme.

File call chain deliver-2.puml (note the 'remove @unlinked' usage here):

Plain Text
 
@startuml deliver-2.puml
  !include inventory.puml
  header deliver-2

  Rel(sm,amd_db,"JDBC CRUD calls","psql")
  Rel(batch, queue, "Consume each request message", "AMQP (R)")
  Rel(batch, amd_db, "Read various very interesting data about the requester company", "JDBC (R)")
  Rel(batch, saccounting, "Get more interesting data from the Accounting system", "HTTPS (R)")
  Rel(batch, sreporting, "Produces a great PDF including great pie charts", "HTTP (W)")
  Rel(batch, smails, "Send an e-mail to original requester with the attached PDF", "SMTP (W)")
  Rel(batch, amd_db, "Store the request data (date, final status...)", "JDBC (W)")
 
  remove @unlinked
@enduml


NOTE

Each call should detail used network protocols along with a modifier flag (R: Read, W: Write, E: Execute). These flags are important to figure out the call intention. More than a single flag on the same call is possible.

In our context, these call chain diagrams provide enough architectural details to code the application. They are the only design documentation we write before actually coding. Apart from them, the real (and best) documentation is the (clean) code itself.

Conclusion

I hope this introduction has aroused your curiosity about coding architectures using Plantuml and C4. 

I will finish with a personal feeling I can't formally demonstrate but observed many times: the graphical "harmony" of an architectural diagram is directly proportional to its intrinsic quality. It is, therefore, possible to form a first opinion of complex architecture with just a glimpse of the main diagram on the wall...

In the same order of ideas, dependencies diagrams highlight the strategic modules and reflect the balances of power hidden behind the architecture (as expected by Conway's Law).

 

 

 

 

Top