Over a million developers have joined DZone.

Designing a DSL to Describe Software Architecture, Part 1

There's more than one way to describe a software architecture:

Learn how API management supports better integration in Achieving Enterprise Agility with Microservices and API Management, brought to you in partnership with 3scale

Software architecture defines the different parts of a software system and how they relate to each other. Keeping a code base matching its architectural blueprint is crucial for keeping a complex piece of software maintainable over its lifetime. Sure, the architecture will evolve over time, but it is always better to have an architecture and enforce it than giving up on keeping your code organized. (See my recent blog post: Love your Architecture).

The problems start when it comes to describing your architecture in a formal and enforceable way. You could write a nice Wiki article to describe the architecture of your system, or describe it on a Powerpoint slide or with a set of UML diagrams; but that would be quite useless because it is not possible to check in an automated way whether or not your architecture is respected by the code. And everybody who ever worked on a non-trivial project with more than 2 developers knows that rules will be broken. That leads to an ever increasing accumulation of architectural debt with all kinds of undesirable side effects for the long term sustainability of a piece of software. You could also use Sonargraph 7 or similar tools to create a graphical representation of your architectural blueprint. That is already a lot better because you can actually enforce the rules in your automated builds or even directly in the IDE. But it also means that everybody who wants to understand the architecture will need the tool to see it. You also will not be able to modify the architecture without having access to the tool.

Wouldn’t it be nice if you could describe your architecture as code, if you had a DSL (domain specific language) that can be used by software architects to describe the architecture of a system and that is expressive and readable enough so that every developer is able to understand it? Well, it took us a while to come up with that idea, but now I believe that this is the missing puzzle piece to significantly boost the adoption of formalized and enforceable software architecture rules. The long term benefits of using them are just to good to be ignored.

When starting to design the language we came up with some essential requirements:

  1. It should be possible to describe an architecture in a set of files. Some of them should be generic enough so that they could be reused by many projects, e.g. a generic template describing the layering of a system.
  2. It should be possible to describe an architecture in form of several completely independent aspects. E.g. one aspect describes layering, another aspect describes components and a third aspect looks at separation of client and server logic.
  3. On the other hand the language should also be powerful enough to describe the complete architecture in a single aspect.
  4. The DSL must be easy to read and easy to learn.

Now that we are using the language to describe the architecture of our own software we found the second point to be especially powerful. Within minutes we were able to detect issues that were not easy to detect with our Sonargraph 7 infrastructure.

Architecture as Code

Basic Building Blocks: Components and Artifacts

To describe architecture in a formal way we first need to think about the basic building blocks that we could use to describe the architecture of a system. The smallest unit of design is what we call a physical component (or just component in its short form). For most languages like Java or C# that would be just a single source file, for other languages like C or C++ a component is the combination of a header file with the associated source files that implement the elements declared in the header. To define an architecture you would group associated components into architectural artifacts. Then you could group several of those artifacts together into higher level artifacts and so on. For each artifact you would also define which other artifacts can be used by them.

To define which components would belong to a certain artifact you need a way to address components in a way that is independent from the physical location on your file system. We need a naming scheme for components.

"Core/com/hello2morrow/Main"                          // Main.java in package com.hello2morrow
"External [Java]/[Unknown]/java/lang/reflect/Method"  // The Method class from java.lang.reflection
"NHibernate/Action/SimpleAction"                      // SimpleAction.cs in subfolder of NHibernate
"External [C#]/System/System/Uri"                     // An external class from System.dll

For internal components (components that actually belong to your project) we use the following naming strategy:

module/rel-path-to-project-root-dir/source-name

For external components (third party components used by your project) we use a slightly different strategy. Here we might not have access to any source files:

External [language]/jar-or-dll-if-present/rel-path-or-namespace/typename

Now we can use patterns to describe groups of components:

"Core/**/business/**"             // All components from the Core module with "business" in their name
"External*/*/java/lang/reflect/*" // All components in java.lang.reflect

As you can see a single ‘*’ matches everything except a slash, ‘**’ matches over slash boundaries. You can also use ‘?’ as a wildcard for a single character.

Now we can build our first artifacts:

artifact Business
{
    include "Core/**/business/**"
}

artifact Reflection
{
    include "External*/*/java/lang/reflect/*"
}

We grouped all components from module “Core” with “business” in their name into an artifact named “Business.” The reflection classes from the Java runtime are now in their own artifact called “Reflection.” Artifacts can also have “exclude” filters. They help you to describe the content of an artifact with an “everything except” strategy. Exclude filters will always be applied after all include filters.

Interfaces and Connectors

To define allowed relationships between artifacts it helps to use some simple and effective abstractions. Lets assume every artifact has at least one incoming and one outgoing named port. Artifacts can connect to other artifacts by connecting an outgoing port with an incoming port of another artifact. We will call outgoing ports “Connectors” and incoming ports “Interfaces.”  By default each artifact always has an implicit connector called “default” and an implicit interface also called “default.” Those implicit ports always contain all the elements contained in an artifact, unless redefined by the architect.

Let us now connect our artifacts:

artifact Business
{
    include "Core/**/business/**"
    connect default to Reflection.default
}

artifact Reflection
{
    include "External*/*/java/lang/reflect/*"
}

This will allow all elements contained in “Business” use all elements contained in “Reflection” by connecting the default connector of “Business” with the default interface of “Reflection”. In our architecture DSL you can also write this shorter:

artifact Business
{
    // ...
    connect to Reflection
}

// ...

If we reference an artifact without explicitly naming a connector or an interface the language will assume that you mean the default connector or interface. Connections can only be established between connectors and interfaces. The syntax of the connect feature is as follows:

connectconnectorName? tointerfaceList

The interface list is a comma separated list of interfaces to connect to. The connector can be omitted, in that case the default connector will be used.

Now let us assume that we would not want anybody to use the class “Method” of the reflection artifact. This can be achieved by redefining the default interface of “Reflection”:

artifact Reflection
{
    include "External*/*/java/lang/reflect/*"

    interface default
    {
        include all
        exclude "**/Method"
    }
}

Doing that will makes it impossible to access the Method class from outside the “Reflection” artifact because it is not part of any interface. Here we used an include all filter to add all elements in “Reflection” to the interface. Then by using an exclude filter we took out “Method” from the set of accessible elements in the interface.

Most of the time you will not need to define your own connectors. This is only necessary if you want to exclude certain elements of the using artifact from accessing the used artifact. Using more than one interface on the other hand can be quite useful. But for the sake of completeness let us also define a connector in “Business”:

artifact Business
{
    include "Core/**/business/**"

    connector CanUseReflection
    {
        // Only include the controller classes in Business
        include "**/controller/**"
    }

    connect CanUseReflection to Reflection
}

// ...

Now only classes having “business” and “controller” in their name will be able to access “Reflection.”

Let us do something more advanced and assume that the architect wants to make sure that “Reflection” can only be used from elements in the “Business” layer. To achieve that we can simply nest “Reflection” within the “Business” artifact and hide it from the outside world:

artifact Business
{
    include "Core/**/business/**"

    hidden artifact Reflection
    {
        // Need a strong pattern to bypass patterns defined by parent artifact 
        strong include "External*/*/java/lang/reflect/*"
    }
}

By declaring a nested artifact as hidden it will be excluded from the default interface of the surrounding artifact. We also don’t need to connect anything because parent artifacts always have full access to the artifacts nested within them. In general an artifact can access anything that belongs to itself including nested artifact and all components that are not part of any artifact. Access to other artifacts requires an explicit connection.

Notice the strong include pattern. Without using a strong pattern the elements belonging to reflection would not make it past the pattern filters defined by “Business.”

You can also use the local modifier for artifacts. A local artifact will not be part of the default connector of the surrounding artifact.

If you later find out that another part of your software needs access to “Reflection” too you have several options. You could add an interface to “Business” exposing “Reflection” or you could again make a top level artifact out of it. Here is how you’d expose it:

artifact Business
{
    include "Core/**/business/**"

    hidden artifact Reflection
    {
        // Need a strong pattern to bypass patterns defined by parent artifact 
        strong include "External*/*/java/lang/reflect/*"
    }

    interface Refl
    {
        export Reflection
    }
}

With export you can include nested artifacts or interfaces of nested artifacts in an interface. Now clients can connect to the “Business.Refl.” The counterpart of export for connectors is the keyword include. It will include nested artifacts or connectors from nested artifacts in a connector.

In that particular example we can expose “Reflection” even more easily:

artifact Business
{
    include "Core/**/business/**"

    exposed hidden artifact Reflection
    {
        // Need a strong pattern to bypass patterns defined by parent artifact 
        strong include "External*/*/java/lang/reflect/*"
    }
}

Now that looks a little strange on first sight, doesn’t it – exposed and hidden at the same time? Well, hidden will exclude “Reflection” from the default interface of “Business,” while exposed makes it visible to clients of “Business.” Now clients can connect to “Business.Reflection” which is a shortcut for “Business.Reflection.default.” If “Reflection” had more interfaces they could also connect to those other interfaces.

That brings us to another important aspect of our architecture DSL – encapsulation. An artifact only exposes its interfaces or the interfaces of exposed artifacts to its clients. It is not possible for a client to connect to a nested artifact until it is explicitly exposed by its surrounding artifact.

At the end of this post let us have a look at the general syntactic structure of artifacts, interfaces and connectors:

artifact name
{
    // include and exclude filters
    // nested artifacts
    // interfaces and connectors
    // connections
}

interface iname
{
    // include and exclude filter
    // exported nested interfaces
}

connector cname
{
    // include and exclude filters
    // included nested connectors
}

The order of the different sections is important. Not following this particular order will lead to syntax errors.

We implemented this language in the 8.6 release of Sonargraph-Explorer. We are in the process of rolling out this language to all the other Sonargraph product variants. You can experiment with the language by obtaining a free evaluation license of Sonargraph-Explorer.

This concludes the first article in the series. We have covered the basics of our DSL and will progress to more advanced concepts in the upcoming posts. Please let me know what you think about this approach and how it could be improved. Would you use it in your project?

Unleash the power of your APIs with future-proof API management - Create your account and start your free trial today, brought to you in partnership with 3scale.

Topics:
dsl ,architecture

Published at DZone with permission of Alexander Von Zitzewitz, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

The best of DZone straight to your inbox.

SEE AN EXAMPLE
Please provide a valid email address.

Thanks for subscribing!

Awesome! Check your inbox to verify your email so you can start receiving the latest in tech news and resources.
Subscribe

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}