$ go build -o ./engine application/main.go
$ ./engine rest
$ ./engine rest --config .env.production$ ./engine cron-update-payment
$ ./engine cron-update-payment --dry-run
$ ./engine cron-update-payment --batch-size 100This article is a continuation of the previous one due to the newsletter’s limitations. I can’t send a 20-minute article in a single email to readers because it would degrade the experience (some text would be missing). Instead, I break it down into multiple articles.
Combining Modular Monolith and Hexagonal Architecture while Maintaining Domain Driven Design Principles (part 1) — previous article
Developing Modular Monolith and Hexagonal Architecture in Golang while Maintaining Domain Driven Design Principles (part 2) — this article
So, if you haven’t read the previous part, please do so before continuing. It’s required to read it first to understand my reasoning behind the implementation in this article.
The code repository of this article can be seen here: github.com/bxcodec/golang-ddd-modular-monolith-with-hexagonal. I will reference some code from the repository, and you might need to open it while reading this.
The first step before developing a modular monolith is to draw the domain boundaries. It’s sometimes hard to do, but it will pay off in the long run.
A good starting point is to follow Domain-Driven Design (DDD), such as using
Event Storming
Or, Domain Storytelling for domain modeling.
The goal is to define a bounded context within a sub-domain and group related elements so they continue to communicate using the same ubiquitous language. And later, within the same bounded context, we can group them into a single module.
So, back to our example of a payment service. We will group them into two domains — note that in DDD, the payment settings service and payment service are often still in the same bounded context. For simplicity, we can imagine building a payment gateway that supports multiple payment methods. We can also assume that payment settings can be extracted as a different bounded context.
This bounded context will isolate all modules within their respective packages. As you can see, I have two domains, payment and payment-settings. Both are separated into different modules.
Inside each module, we will see: at least four types: factory, internal, module.go (module definition), and public-api.go (or just payment.go|setting.go| etc)—basically the public API definition for that module.
The factory essentially acts as the builder, the wiring manager, and the dependency injection manager. Basically, it helps us connect the module that will be exposed and assembled in the main.
In Golang, there is no granular access level / package-level modifier like private, protected, or public. Instead, Golang typically uses two levels of visibility:
Exported (Public): functions, structs, or variables that start with a capital letter can be accessed from outside the package.
Unexported (Private): those beginning with a lowercase letter are only accessible within their own package.
In Golang, there is also a concept of an “internal” package. Any code placed inside the internal directory won’t be accessible from external packages. Because of Golang’s limited features, I use the “internal” package to organize my code. I place all non-public APIs within internal, as illustrated in the screenshot below.
There are at least three packages: adapter, ports, and service. The adapter contains additional components that implement the technical details for the ports. The service can be considered the “domain” from a Hexagonal architecture perspective. However, since we are also implementing a Modular Monolith in which the public API must be accessible externally, I refer to it simply as “service.”
This file will be a simple wrapper struct that holds information about the module itself. While it can be imported into other modules if needed, I strongly advise against this in Go to avoid circular dependencies. Instead, I use it here to organize components related to the module, such as controllers, services, and others.
payment.go | setting.go | etc
Other than the module.go in the modules/<module-name> package, the rest serves as the “public API,” which defines the module itself. This public API will serve as the reference point for other modules that are interested in or depend on it.
As we can see, we define the public API of the payment module here
The Schema (Payment struct)
The API contract ( IPaymentService interface)
Other modules can access this public API. They can import it directly or create a local copy in their own module, using this definition as a reference, as I explain further below.
Golang differs from Java, C#, and other programming languages. In Golang, there is a principle that most Golang engineers are already aware of: interfaces are usually defined as domain abstractions for external dependencies.
In our previous diagram,
I show that PaymentService is likely “importing” the interface definition from PaymentSettings. This shows a simulation of how the module communicates with other modules via the public API.
In Golang, instead of importing the PaymentSettingService interface from the PaymentSetting module, the Payment module should define the interfaces it needs from the PaymentSettings module or any other modules.
This can also be viewed as specifying the necessary ports (hexagonal ports) for Payment while abstracting the different modules. This also illustrates Golang’s idiomatic practice. Even if PaymentSettingService has 10 methods, PaymentService only needs 1-2 methods, so it only defines a local interface with the methods it requires from PaymentSetting. This also isolates the domain module from the other domain modules if we apply the hexagonal architecture principle.
The trade-off of this practice is that there will be a time when the contract between the module we import and the interface we define locally is inconsistent. But it introduces very loose coupling between modules. With this approach, each module can be developed independently.
You might ask why it can’t be in the ports package. “Since we are also following the Hexagonal Architecture, shouldn’t this be done alongside the other ports?”
The answer is about the modifier.
If you import the ports interface directly, eg, Go will throw a compilation error. You can’t import this interface because of “internal”.
For example, when I tried to import payment settings “internal” ports directly.
And compile it:
I got an error:
modules/payment/internal/service/service.go:6:2: use of internal package github.com/bxcodec/golang-ddd-modular-monolith-with-hexagonal/modules/payment-settings/internal/ports not allowed
And that’s expected; that’s what we want —why we introduce the “internal” package and put all internal implementation inside the module.
Think of each module as a microservice. Does it make sense for one microservice to access another service’s database directly? No, it doesn’t, as I mentioned earlier — that’s considered an anti-pattern. Instead, microservices should communicate through a public API.
Therefore, we adopted a similar approach to the modular monolith by separating the public API from other ports. Although it uses a hexagonal architecture, it is still considered a port; this distinction makes upgrades easier. When a module needs to be converted into an independent microservice in the future, it can be done smoothly because it interacts only through the public API, not with detailed implementations.
Another key aspect in building the modular monolith is managing the database. Similar to an independent microservice, it should never access the database of another service. This is considered an anti-pattern.
In a modular monolith, even though it’s a single application with a single physical database, I advise against sharing tables across the repository or data layer. Instead, whenever data is required from another module’s table, always retrieve it via the “public API” interface that interacts with different modules.
I’m not sure about other database platforms, but in Postgres, there’s a concept called “Schema” that enables logical separation within the database. Even with just a single database, we can implement logical isolation at the module level.
If you look at the repository I have, you will see I have two schemas.
payment_module
payment_settings_module
Both are defined in the database and used during query execution. This setup provides flexibility, especially when planning to migrate a module to an independent microservice. It simplifies the process because there’s no direct database-level connection with other modules.
Okay, we have finally isolated all of them into modules. We defined the bounded context. We grouped them into one module. The next step is to assemble them, and they will finally become an application.
Following the hexagonal principle again, we also apply it to how we organize the project, ensuring the application has its doors defined. Could be the REST, gRPC, CLI for cron, Queue consumer, etc.
So with the same application, what we have will be like a super app that contains all the possible processes. In my code example, I define a cmd package that will assemble the application I want.
As shown, I have cron and rest commands. When I compile the project into an executable binary later, I can run both of them using the command below.
$ go build -o ./engine application/main.go
$ ./engine rest
$ ./engine rest --config .env.production$ ./engine cron-update-payment
$ ./engine cron-update-payment --dry-run
$ ./engine cron-update-payment --batch-size 100And since we already modularized the domain, adding them to the command should be straightforward. Can see the code example I have.
Refer to lines 58-69, where I use the factory to prepare the modules. This setup enables me to run the CLI command with all business logic encapsulated within its respective domain.
Alright, I think that’s all the long explanation about applying DDD Modular monolith with Hexagonal architecture in Golang. It might be a bit different from other thoughts, but I applied this based on what I know and I have experienced.
Designing software is always interesting. Initially, I considered using C#.NET since my client from SoftwareArchitect.ID is using C#.NET, or another option, I am also considering writing it in Node.js. However, after reviewing some examples, I found that both languages are well-established, and I came across some good examples.
For C#.NET, I reviewed this repository: github.com/kgrzybek/modular-monolith-with-ddd, although it differs from my current Golang version due to language limitations. I implemented the same principles while also aligning with idiomatic Golang practices. Additionally, I am also applying the Hexagonal architecture, so I need to find a good balance. This process is both interesting and enjoyable.
Let me know your thoughts. Maybe you have a better idea. We can probably discuss it here (in the comment section) or in the GitHub repository (feel free to open a GitHub issue to start the conversation).
Alternatively, if you’ve identified additional use cases that don’t fit, we can discuss them and develop a better solution.
One thing is sure: although it’s a monolith, it’s not the usual kind but a modular monolith. Therefore, avoid crossing domain boundaries. If two domains communicate excessively, it likely makes sense to merge them into a single domain. This principle also applies to database access.
Always invest the time in domain separation, as defining the bounded context significantly benefits modular monolith development. It helps avoid hacks and common mistakes.
And finally, the code repository of this mini workshop can be seen here: github.com/bxcodec/golang-ddd-modular-monolith-with-hexagonal
References and Learning
Since establishing SoftwareArchitect.ID as my brand, I’ve worked as a fractional technical leader, mainly as a fractional software architect, for several years. My experience spans from small startups to medium-sized companies, more recently, serving as an advisor and architect for an enterprise migrating to the cloud and transitioning from a large monolith to microservices. Contact me at iman[at]softwarearchitect.id for consultation or a fractional role as an architect or tech lead related to building a scalable product and platform
No posts