Modular Monolith: How to tightly isolate data and code without switching to microservices

published: ⏰ 9

I think many people have faced the challenge of correctly defining the functional boundaries of an application or service, and then implementing an architecture that would ensure these boundaries are preserved in the code and database. While approaches to web applications and server-side logic have long been well-known, databases are often overlooked.

Introduction

What’s the problem? I won’t overcomplicate it; for example, I’ll use a simple, service with a three-layer architecture.

LayerDescription
1Presentation LayerWeb application on a framework
2Business Logic LayerServer side with the service’s business logic
3Data Storage LayerDatabase that stores the service’s state

Someone might say, “Oh, my God, it’s a monolith!” Yes, it’s a monolith (modular), and what? :)

How to delineate functional boundaries and create modules for the first two layers of information is well-known; the methods and approaches are well-known (some of them are discussed below). But the third layer isn’t usually given much thought, and it often ends up being a jumble of tables. Unless, of course, there’s a dedicated DBA in blue briefs over red trunks who’ll come out of nowhere and make “everything good.”

I’ll say right away that I might have missed something, and there are other ways to organize the code and database. I’ll give obvious examples of what immediately comes to mind.

Presentation Layer

In the presentation layer, we have various ways to organize modularity:

ApproachDescription
1The Path of Self-DeceptionFolder and file structures in shared code.
2The Path of Real SeparationWe separate parts of the application into separate modules, which are then assembled as dependencies into the final application.
3MicrofrontendsAn option that allows you to assemble an application from different parts, which can be built separately and even reside on different servers.

The first option is clear, although I may be mistaken – to me, it’s still the same old code dump, just wrapped in a pretty package. Over time, it starts to smell, and even the pretty package doesn’t help. The limitations are solely based on agreements between developers. Examples of approaches:

  1. feature-sliced – an architectural approach that allows you to separate an application by different criteria: features, entities, shared objects, widgets, and pages. I think it’s no longer in favor, but I’m not very familiar with the latest trends in front-end development.
  2. Clean Architecture – a development approach that allows you to separate an application by layers: entities, usecases, UI, API, etc. They dug this up from Uncle Bob and are fussing over it like it’s the Holy Grail.

The second option is using regular modules, such as workspace in a monorepo. This is noticeably better, but still in a single project and a single repository. However, certain development challenges are starting to arise. For example, with HMR, not everything is deployed automatically when code changes.

The third option is a more explicit separation into independent parts, which can even be physically deployed separately. The problems here are similar and even more numerous than in the previous option. And as far as I know, there are no simple solutions. What we can use:

Business logic layer

When it comes to the server layer, the situation in terms of available approaches is somewhat similar to the presentation layer. Since I’ve spent the better part of my life developing Java, all my subsequent examples will be given from the perspective of Java development, but I believe there are similarities for other development languages.

We can use the following code organization options:

ApproachDescription
1The same old self-deceptionEverything in a single module, in the Clean Architecture style. They even came up with additional hacks to punish “bad” developers.
2Modularity at the module (library) levelWe move business logic into separate libraries, which are then included as dependencies.
3Modularity at the microservice levelWe move domain-specific business logic into separate services that communicate with each other via API (sync/async).

The first option has all the same problems as the presentation layer. Examples of tools:

  1. Spring Modulith – enforces certain code organization rules with violation checks. As far as I remember, you also get documentation as a bonus.
  2. ArchUnit – allows you to check architectural constraints in your code (tests), for example, to ensure that module A doesn’t depend on module B.

In the second option, we achieve modularity at the library level. Here, the build environment itself can “slap” you on the wrist, pointing out circular dependencies, hidden classes, or attempts to couple something uncoupled. Whether you like it or not, you’ll have to properly modularize your code. There are entire frameworks for more convenient work with this, like OSGi (although they come with their own challenges). I’m not sure how comparable this is to Java modules, but I suppose it could be considered an alternative. Examples of tools used:

  1. Java Service Loader.
  2. OSGi.
  3. String Boot starters.
  4. There’s also EAR/WAR in Java EE for deployment to application servers, but it seems few people use this anymore.

The third option is microservices and physical separation. In this case, we achieve complete independence between services, but also all the associated problems and limitations. Highly recommended for those who enjoy System Design interviews and fintech/bigtech enthusiasts. For other projects, it depends.

Data Storage Layer

And here we are. A database isn’t code, after all; in fact, it can even contain more code than the application itself in certain cases. However, if you view a database solely as storage, you’ll want to somehow separate this data.

What’s in a database:

ApproachDescription
1Oddly enough, again a path of self-deceptionWe dump everything into one database, trying to somehow distinguish tables by prefixes.
2SchemasThis is a way to isolate data within a single database (not to be confused with a DBMS).
3Separation into different databasesA separate database for each domain, which is not always possible.

The first option is very common in projects; simple and straightforward, but not scalable. Monoliths are out of the question when a single database can contain thousands of tables with names encoded by their authors. A conscious and rigorous approach to naming and documenting the model is required.

A simple example for two modules with prefixes, respectively, security and full-text-search:

Text
- sec_user      [table]
- sec_role      [table]
- fts_document  [table]
- fts_metadata  [table]

What makes this organization inconvenient:

  • There can be conflicts with table names, indexes, constraints, and so on.
  • When the number of tables exceeds a certain limit, it becomes difficult to navigate them.
  • Table names can also have length limitations (for example, 63 characters in PostgreSQL).
  • Unclear prefixes that need to be remembered.

In the second option, all tables are placed in separate schemas, which solves all the above problems. However, there are also disadvantages:

  • An additional “parameter” in the form of the schema name appears in all DML/DDL operations.
  • Schema support is required from both the DBMS and application frameworks.

The example is similar to the one above, but now the structure looks like this:

Text
- security      [schema]
  - user        [table]
  - role        [table]
- fts           [schema]
  - document    [table]
  - metadata    [table]

Putting it all together

The microservices option makes no sense. Generally, one microservice == one database, and data isn’t shared between them. Interaction between services occurs through an API–interaction contracts are defined, and data is formalized.

I personally don’t really like code-level separation, through various agreements and props. What will be discussed below for modules as dependencies (libraries) can be applied in this case, in some form.

Returning to the beginning of the article–we need to organize domain separation at the database level for a typical three-tier system. Putting all of the above together, we get the following structure:

LayerApproachComment
PresentationModulesMicrofrontends for one service seem redundant. But I suspect there are samurai with their own way.
Business LogicModulesOne service, essentially a modular monolith. I know, it’s unpleasant, but you have to be patient :)
Data StorageSchemasOne service, on one server, with one database and several schemas.

To make things more than just words, I’ll show examples in:

In the presentation layer, tekBlueprint.frame modules are organized into separate packages (workspaces).

Shell
tb-frame-js > npx lerna list
 
@tb-frame/core
@tb-frame/security-ui-prime
@tb-frame/ui-prime
@tb-frame/ui-react

The necessary components are connected to the service. For example, for user and permissions management functionality, you need to add @tb-frame/security-ui-prime. As a result, the application will have a separate module with a user management UI, which will bring with it:

  • UI components.
  • Routes for the router.
  • Localization.
  • Additional services, hooks, and other SPI required for connection.

The screenshot below shows that the left panel contains a SECURITY section and a user editing form, which belongs to the corresponding module. The module’s entities and code are seamlessly integrated into the main application.

On the business logic layer, tekBlueprint.frame modules are organized as Maven submodules (modules).

Shell
tb-frame-java > ./mvnw compile

tekBlueprint: frame                                                [pom]
security                                                           [pom]
security :: spi                                                    [jar]
security :: svc                                                    [jar]
security :: protocol :: Local                                      [jar]
security :: protocol :: LDAP                                       [jar]
security :: protocol :: OIDC                                       [jar]
security :: preset :: basic                                        [jar]
security :: starter                                                [jar]
storage                                                            [pom]
 ...
search                                                             [pom]
 ...

They can also be connected to the final application as needed. All interaction with them is via SPI (class interfaces), without access to the modules’ internal logic. For example:

ModuleFunctionality
security :: svcAdds general functionality for managing roles and permissions with a domain data model (plus Liquibase).
security :: protocol :: LocalAdds functionality for authenticating local users using a login and password pair.
security :: protocol :: OIDCAdds functionality for authenticating external users using JWT.
security :: preset :: basicAdds basic functionality for managing user profiles and the user themselves (plus Liquibase).

If you look at the structure of the tb_kbase database, you will see that it consists of several schemes.

Text
public
 └──Tables
     ├──databasechangelog
     ├──databasechangeloglock
     ├──tb_component_index
     ├──tb_component_meta
     ├──tb_component_repository
     ├──tb_key_value
     └──tb_profile
security
 └──Tables
     ├──base_user
     ├──access_group
     ├──access_perm
     ├──access_role
     ├──access_subject
     ├──group_to_subject
     ├──perm_to_role
     ├──perm_to_subject
     ├──role_inherit_role
     ├──role_to_group
     └──role_to_subject

The ‘public’ schema contains tables used by the application itself (by the way, that’s where you can see the tables with prefixes). The general migration log for Liquibase is also located there.

The ‘security’ schema contains tables associated only with the ‘security :: *’ module. This means there’s a clear separation of tables based on the modules used in the service. I think you could further configure access rights to database objects more granularly. For example, in the ‘security’ schema, you could create more stringent restrictions on certain DML/DDL operations, while leaving everything as is in the ‘public’ schema.

That’s basically it. Naturally, some maintenance “magic” will be required to connect modules to the main application. But this is an unavoidable evil unless you strive for full automation and universalization, thereby losing flexibility.

Development Support

If your project uses JPA, you only need to specify the required schema in the entity table parameters. The ORM takes the specified schema into account and generates SQL queries based on it.

Java

@Entity
@Table(schema = SecurityConstants.DB_SCHEMA)
public class AccessGroup {
}

The Liquibase migration scripts are also simple. Specify the desired schema in changeSet.

XML

<changeSet>
    <preConditions onFail="MARK_RAN">
        <not>
            <tableExists tableName="access_group" schemaName="SECURITY"/>
        </not>
    </preConditions>
    <createTable tableName="access_group" schemaName="SECURITY">
        ...
    </createTable>
</changeSet>

Conclusion

Whether or not to use this approach is up to you. It largely depends on the type of project and its scale. In my case, it proved convenient and I haven’t encountered any particular issues so far. The other tekBlueprint projects are also divided into libraries and separate modules.

Modulith Modules Layers Database