Loosely Coupled Monolith


Microservice architecture is a popular approach to building scalable and maintainable systems. It allows teams to work on different parts of the system independently and deploy them independently. I have an experience working with a large microservice system, and feel the pain of it.

There are many benefits of using a microservice architecture, such as:

However, there are also some challenges to implementing a microservice architecture, such as:

Perhaps we can actually write a monolith that looks like a microservice?

What is loosely coupled monolith?

I first encountered this concept from CodeOpinion. To me, it’s a different way of writing monolith than most of what the frameworks offer today.

Most frameworks I worked with has some kind of the following characteristics: All database models can be accessed by any controller. Any controller can join tables however they want. In Rails, all the models sits in the same models/ directory and all controllers are free to access any of the models.

In loosely coupled monolith, we will have a different sets of rule:

Example

I recently implement an API server for my new personal project using Go. This is what the project structure looks like:

.
├── cmd
│   └── web
│       └── main.go
├── internal
│   ├── apps
│   │   ├── services.go
│   │   └── user
│   │       ├── actor.go
│   │       └── user.go
│   ├── managers
│   │   ├── managers.go
│   │   ├── user.go
│   └── workers
│       └── workers.go
├── migrations
│   ├── clickhouse
│   │   └── 20250405232355_create_event_table.sql
│   └── postgres
│       └── 20241119142056_create_user_tabl.sql
├── pkg
│   ├── apiserver
│   │   ├── router.go
│   │   └── routes
│   │       └── user.go
│   ├── infrastructure
│   │   ├── actor
│   │   │   └── goakt.go
│   │   ├── db
│   │   │   ├── clickhouse.go
│   │   │   └── postgres.go
│   │   └── s3
│   │       └── s3.go
│   └── utils
│       └── hash.go
├── Makefile
├── docker-compose.yaml
├── Dockerfile
├── go.mod
├── go.sum
└── integration_tests
    └── healthcheck.hurl

*Many of the files are removed for brevity

Data always flows like this:

Router -> Manager -> App -> Database

Each components has their own responsiblities:

In my case, for now, there is no way an app can call another app directly, only managers can them and pass the data between them.

These are examples of each of the components looks like:

// routes/user.go
func (rs RouteService) newUser(c fuego.ContextWithBody[NewUserRequest]) (*UserResponse, error) {
	body, err := c.Body()
	if err != nil {
		// Validate the input
		return nil, err
	}

	u := model.User{
		Username: body.Username,
		Email:    body.Email,
	}

	result, err := rs.manager.NewUser(c.Context(), u)
	if err != nil {
		return nil, fuego.HTTPError{
			Title:  "Failed to create user",
			Detail: err.Error(),
			Status: http.StatusInternalServerError,
		}
	}

	return &UserResponse{
		ID:       int(result.ID),
		Username: result.Username,
		Email:    result.Email,
	}, nil
}

// managers/user.go
func (m Manager) NewUser(ctx context.Context, user model.User) (*model.User, error) {
	tx, err := m.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer tx.Commit()

	result, err := m.services.UserService.CreateUser(ctx, tx, user)
	if err != nil {
		tx.Rollback()
		return nil, err
	}

	// Schedule a background job to send welcome email to the user

	return result, nil
}

// apps/user.go
func (s UserService) CreateUser(ctx context.Context, dbtx qrm.Queryable, user model.User) (*model.User, error) {
	slog.Info("User Service: creating a new user")

	result := model.User{}
	stmt := User.INSERT(User.Username, User.Email).MODEL(user).RETURNING(User.AllColumns)

	err = stmt.QueryContext(ctx, dbtx, &result)
	if err != nil {
		slog.Error("User Service: Error inserting user", "err", err)
		return nil, err
	}

	if err := s.eb.Publish(ctx, &eventbus.Event{
		Event: eventbus.EventUserCreated,
		Id:    int64(userId),
	}); err != nil {
		slog.Error("Error publishing user created event", "error", err)
	}

	return &result, nil
}

Notice that, in microservice world, typically the manager would call User microservice which is typically owned by a dedicated team. That call would then involves a DNS query & resolution, data serialization & deserialization, HTTP connection establishment & closing. Sometimes you need to implement rate limiting at each layer, view, controller & data layer, which is a lot of work.

When it comes to loosely-coupled monolith, the manager starts a DB transaction, call internal service like m.services.UserService.CreateUser(ctx, m.db, user) and it’s done. It’s not sexy, I know. m.services.XXX is my service disovery.

I skip writing unit test and I only write integration tests using Hurl. It gives me full confidence that everything works end to end no matter how I change the internal implementation.

Tradeoffs

The benefits of this approach:

The downsides of this approach are:

Software quality trends

Courtesy of Devgraph.

It takes a longer time to build the foundation since this is not a common architecture compared to Model-View-Controller (MVC) pattern. However, developing a new service is easy, no need a new deployment pipeline, no need for a new subdomain registration, no need for a new Kubernetes deployment manifest etc.

Conclusion

One thing I learned a lot about this architecture is that, exposing the right interface in your implementation is important no matter what architecture you use. If you expose the interface wrongly, its easy to end up in a ball of mud.

I may not understand it fully, but I’m guessing the term ‘Modular Monolith’ used in Shopify and Github refers to the same architecture. CMIIW.