03/21/2024

Scalable Architecture with Sheriff

post banner

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

architecture

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:

  1. 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.
  2. Domain: Represents a bounded context (as per Domain-Driven Design), offering a specific set of features. The domain provides access via a shell.
  3. Feature: A feature is an encapsulated use-case like e.g. order creation. Each feature is self-contained and isolated from other features.
  4. Shared Domain Code: Shared code for a domain.
  5. Shared: Globally shared code.

Feature in-depth

architecture 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.

References


post banner
Michael Berger

About the Author

I am a seasoned Fullstack Software Engineer with a focus on building high-performant web applications and making teams better.

If you are looking for support on building a scalable architecture that grows with your business-needs, revisit and and refine your testing-strategy as well as performance-optimizations, feel free to reach out to me.

I am always looking for new challenges and interesting projects to work on. Check out my website for more information.