Scalable Architecture with Sheriff
In my longterm experience as a software engineer, I saw a lot of growing projects. I saw some projects which handle the growth very well and some which didn’t. One key aspect which I could identify for a successful project is a scalable architecture with clear dependency rules/ boundaries between the differnt parts (modules) of an application.
Too often I saw way to lax rules which will lead to a big ball of mud. In this article I want to present a concept for structuring your applications code, some rules to encapsulate the different parts of your application and ultimately how to enforce these architectural rules in an automated fashion.
The demo application mentioned at the end of the article is a Angular application, however the concept can be applied to any other framework.
The big picture
In above picture we see the overall architecture concept for a single application (which could potentially scale to n-apps).
Let’s break it down:
- Application Shell: The shell is our main application. However the shell does only (lazy) laod the different domains with its features, which do implement our use cases.
- Domain: Represents a bounded context (as per Domain-Driven Design), offering a specific set of features. The domain provides access via a shell.
- Feature: A feature is an encapsulated use-case like e.g. order creation. Each feature is self-contained and isolated from other features.
- Shared Domain Code: Shared code for a domain.
- Shared: Globally shared code.
Feature in-depth
Lets have a closer look at a feature. A feature can be subdivided into the following modules:
- Feature: The feature itself. This is the main module which does implement the use-case. It does contain the business logic and the UI for the use-case.
- UI-components: Some (dumb) ui-components which are specific fo this particular feature
- Types: all models/types which do represent the domain models for this feature.
- Data-access: (data) services which communicate with the backend.
- Util: utility functions which are specific to this feature.
Note The distinction between these parts is heavilty inspired by the Nx recommendation for their library types. However here we just transfer this idea to a module level. A module in our case is a simple directory with a index.ts
file which does export the public api of the module.
Architecture Ruleset
Now as we introduced the general architecture concept, let’s define the ruleset for our architecture:
- We do not want to allow that different domains do communicate directly with each other.
- We do not want that features in the same domain can access each other directly.
- Features can have access to ui-components, data-access, types and util in its feature.
- Ui-components can only access types and utils.
- Data-access can only access types and utils.
- Types can only access utils.
- Utils can access nothing.
Enforcing the ruleset
With Nx we do already have a nice toolset to enforce architectural-bounderies, the so-called module-boundaries. However to implement the ruleset with Nx we need to create a lot of libraries, which is for some projects not feasible.
As an alternative I want to present a lightweight solution called sheriff which is currently under development.
Enforcing the ruleset with sheriff
Let`s first install sheriff:
npm install @softarc/sheriff --save-dev
Next we need to create a sheriff configuration file. The sheriff configuration file does define the ruleset for our architecture. Here is an example configuration file:
export const sheriffConfig: SheriffConfig = {
version: 1,
tagging: {
// the tags in <> are placeholders and are resolved by our actual directory structure
'apps/<application>/src/app/<domain>/<feature>/<type>': [
'app:<application>',
'domain:<domain>',
'feature:<feature>',
'type:<type>',
],
},
depRules: {
/**
* this will disallow that applications can import from each other
*/
'app:*': [sameTag],
/**
* this will disallow that domains can import from each other
*/
'domain:*': [sameTag, 'shared'],
/**
* this will disallow that features within the same domain can import from each other
*/
'feature:*': [sameTag, 'shared'],
'feature:shared': [sameTag, 'shared'],
'type:shell': ['type:smart-components'],
'type:smart-components': [
'type:util',
'type:types',
'type:data-access',
'type:ui-components',
],
'type:ui-components': ['type:util', 'type:types', 'type:ui-components'],
'type:data-access': ['type:util', 'type:types', 'type:data-access'],
'type:types': ['type:types'],
'type:util': ['type:util', 'type:types'],
},
};
Now that we have sheriff setup the last thing is that we need to structure our application directory according to our defined ruleset.
We defined our general structure like this: apps/<application>/src/app/<domain>/<feature>/<type>
. That means we have a directory called apps (which contains all our applications, if we speek of a monorepo containing multiple apps).
Lets call the the applicatoin simply demo
. Within the src/app
directory we will have n-directories for our domains and within theses directories then our features and type-folders.
Here is an example directory structure:
/customer
/feat-customer-support
/data-access
/types
/ui-components
/smart-components
/util
Looks familiar? That is exactly the structure we defined in our architecture concept and the basis for our sheriff-config.
To make sheriff work we just need to make sure that every directory of our type
-placeholder (like data-access
, types
, ui-components
, smart-components
, util
) does contain a index.ts
file which does export the public api of the module. Sheriff will take these index.ts
files to determine the dependencies between the different modules.
Conclusion
This article presents a foundational strategy for developing scalable applications by enforcing clear architectural boundaries. The source code and further examples can be explored here.
Embracing these principles not only facilitates growth but also ensures that your project remains manageable and adaptable in the face of evolving requirements.