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:
connect [connectorName] to interfaceList
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.
A dependency from a component A to another component B is not an architecture violation if any of the following conditions is true:
-
Either A and/or B do not belong to any artifact.
-
A and B belong to the same artifact.
-
The artifact of B is nested in the artifact of A.
-
There is an explicit connection from a connector that contains A to an interface that contains B.
-
B belongs to the default interface of a "public" artifact that is a sibling of a artifact that has a default connector containing A. The artifact of A must be defined before the artifact of B. In other words, "public" artifacts are accessible by sibling artifacts defined above them. ("public" will be introduced later)
-
The artifact of A or one of its parent artifacts is "unrestricted" and B is assigned directly or indirectly to a sibling of the unrestricted artifact. In other words, unrestricted artifacts have access to all of their siblings. ("unrestricted" will also be introduced later)
-
The artifact of A or one of its parent artifacts is "strict" (i.e. is a strict layer) and B is assigned directly or indirectly to the next following sibling of the strict artifact. More precisely: A must be part of the default connector of the strict artifact, while B must be part of the default interface of its next sibling. Strict layers are allowed to access the layer (artifact) directly below them.
-
The artifact of A or one of its parent artifacts is "relaxed" (i.e. is a relaxed layer) and B is assigned directly or indirectly to the any of the siblings of the relaxed artifact that are defined after it. More precisely: A must be part of the default connector of the relaxed artifact, while B must be part of the default interface of any of its siblings that are defined after it. Relaxed layers are allowed to access all the layers (artifacts) defined below them.
Any dependency that does not meet any of the above conditions is considered to be an architecture violation.
"strict", "relaxed" and "unrestricted" are mutually exclusive, i.e. an artifact can have at most one of those three stereotypes.
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 "**/java/lang/reflect/*" interface default { include all exclude "**/Method" } }
Doing that 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 "**/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 artifacts 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.
export and include can be used together with the keyword any. The following example shows how you could explicitly define the default interface and the default connector of any artifact:
artifact SomeArtifact { include "**/something/**" hidden artifact Hidden { // ... } local artifact Local { // ... } artifact Nested { // ... } interface default { include "**" export any // will export 'Local.default' and 'Nested.default' } connector default { include "**" include any // will include 'Hidden.default' and 'Nested.default' } }
If you use any by itself it will include all nested artifacts except hidden artifacts for export and local artifacts for include. You can also explicitly name an interface or a connector of a nested artifact after any. In that case the interface or connector is included if it exists, even if its artifact is marked as hidden or local (see next example).
artifact SomeArtifact { include "**/something/**" hidden artifact Hidden { // ... interface UI { /* ... */ } } artifact Nested { // ... interface UI { /* ... */ } } interface default { export any.UI // will export 'Hidden.UI' and 'Nested.UI' } }
This feature can become quite useful if there are many nested artifacts with a similar structure.
We mentioned before that an artifact can have the modifier unrestricted. This means that dependencies coming out of such an artifact to any of its siblings will not be checked. That can be useful if you are creating an architecture description for an existing system with many violations. By declaring some artifacts as unrestricted you are not being overwhelmed by violations and can focus on the most important violations first. It is also useful for grouping legacy code that you want to exclude form architecture checks.
strict artifact SomeArtifact { include "**/something/**" } strict artifact OtherArtifact { include "**/other/**" } unrestricted artifact Legacy { // All remaining internal components include "**" exclude "External*/**" }
In the example above the two artifacts above "Legacy" have clear architecture rules. They are both defined as strict layers, i.e. they have access to the artifact defined directly below them. All remaining internal components are assigned to "Legacy". Since "Legacy" is unrestricted, its dependencies towards its siblings are not checked. That can be quite useful when you start defining an architecture for an existing system and only want to focus on certain parts of the system. Just keeping components unassigned would have a slightly different effect. I our example we do not allow dependencies from "SomeArtifact" to "Legacy" because we have defined "SomeArtifact" as a strict layer. That restriction could not be checked if we had kept the components in "Legacy" unassigned.
Here is a summary of the different stereotypes that can be used on artifacts:
Stereotype | Description |
---|---|
hidden | The artifact will not be included in its parents default interface. |
local | The artifact will not be included in its parents default connector. |
public | All sibling artifacts defined above this artifact can implicitly access the default interface from this artifact using their default connector. |
unrestricted | All elements of this artifact can freely access the default interfaces of all the siblings of this artifact. |
strict | Creates an implicit connection from the default connector of this artifact to the default interface of its next sibling. (strict layering) |
relaxed | Creates implicit connections from the default connector of this artifact to the default interfaces of all sibling artifacts defined after this artifact. (relaxed layering) |
exposed | Makes this artifact visible to clients of its parent. |
optional | Don't warn if this artifact has no components assigned to it. |
deprecated | Do create a warning if any components are assigned to this artifact. |
Additionally, in order to ease the visualization of the different stereotypes that can modify the behavior of an artifact, Sonargraph uses the following icons and/or decorators:
via "apply" | via "require" | public | local | hidden | ||
---|---|---|---|---|---|---|
artifact | ||||||
unrestricted artifact | ||||||
strict artifact | ||||||
relaxed artifact |
Note that artifacts via "apply" or "require" can also have decorators for public, local and hidden stereotypes.
At the end of this section 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.
Now that we have covered the basic building blocks we can progress to more advanced aspects. In the next section I will focus on how to factor out reusable parts of an architecture into separate files that can best be described as Architecture Aspects. We will also cover the restriction of dependencies by dependency types.