Monolith or Microservices, or Both: Building Modern Distributed Applications in Go with Service Weaver

Shiju Varghese
12 min readJul 9, 2023

In this post, I will give a brief introduction about Google’s new open source project Service Weaver that lets you write your Go application as a modular monolith, which can be deployed it as either monolith or microservices. The source code of the example demo is available on github at: https://github.com/shijuvar/service-weaver/tree/main/orderapp

Monolith Vs Microservices

When you build modern applications, you may go either with monolith or microservices-based architecture, from a scalability perspective. Although monolith applications provide a lot of easiness for development, it has its own limitations when it comes to dealing with scalability challenges. Since last several years, there have been a huge buzzword around microservices-based architecture, and thus, a lot of people have dived into such kind of architecture approaches even without understanding what does it with its pros and cons. Although microservices-based architecture has been a need for many systems when considering the scalability challenges, a lot of people have dived into it just for the sake of being called as microservices. In my country, India, microservices architecture has been using as a fashion statement in software engineering, and most of the people claim that their architecture as microservices regardless of whether it’s truly microservices or not.

In microservices based approach, you split your application into several autonomous services, based on the idea of functional decomposition, mostly around the concept of bounded context of domain-driven design (DDD) principles, and all of these autonomous services can be rolled out independently. Although this provides greater flexibility for scaling your applications, this approach is also having its own challenges. Because data is scattered into individual data stores owned by individual microservices, transactions that span multiple data stores of individual microservices and querying data from multiple data stores, make a lot of challenges, and eventually people end with an Event Sourcing/CQRS based architecture approach that makes other set of problems. A microservices-based architecture solves many challenges but that brings a new kind of problems. Microservices combines logical boundaries where how code is written with physical boundaries where how code is deployed. This makes tight coupling between your logical separation of code with the deployment infrastructure. It was not a surprise that Amazon Prime team moved from a distributed microservices architecture to a monolith.

Monolith or Microservices or Both: Introduction to Service Weaver

It would be great if we could leverage a programming model or a runtime, which could decouples logical boundaries of code with physical boundaries of deployment so that when your start your development you don’t need to tightly coupled your application to be deployed as monolith or microservices. Google’s new open source project Service Weaver provides the idea of decoupling the code from how code is deployed. Service Weaver is a programming framework for writing and deploying cloud applications in the Go programming language, where deployment decision can be delegated to an automated runtime. Service Weaver lets you deploy your application as monolith and microservices. Thus, it’s a best of both world of monolith and microservices.

With Service Weaver, you write your application as modular monolith, where you modularise your application using components. Components in Service Weaver, modelled as Go interfaces, for which you provide a concrete implementation for your business logic without coupling with networking or serialisation code. A component is a kind of an Actor that represents a computational entity. These modular components, which built around core business logic, can call methods of other components like a local method call regardless of whether these components are running as a modular binary or microservices without using HTTP or RPC. When these components are running as microservices or in different machines, Service Weaver uses its RPC mechanism for making the method calls. Otherwise, it will be a local method call. You don’t need to worry about how your components call methods of other components. You can test your Service Weaver applications locally and deploy it to the cloud. Service Weaver lets you focus on what your code does without worrying about where it’s running. You can run your components as a monolithic binary in the same process or as microservices on different machines, and can easily scale up or down to match the scalability challenges. When you build modern applications, it’s important to add observability components. Service Weaver integrated into observability, and has libraries for logging, metrics, and tracing.

Example Demo on Service Weaver

Let’s write a simple demo for understanding how to write applications using Service Weaver. This example demo represents a simple Order placing procedure by removing all complexities in the implementation, and just focusing on the fundamentals of Service Weaver.

The source code of the example is available on github at: https://github.com/shijuvar/service-weaver/tree/main/orderapp

Installing Service Weaver

The command below installs the weaver command to $GOBIN:

go install github.com/ServiceWeaver/weaver/cmd/weaver@latest

For Google Kubernetes Engine (GKE) deployments you should also install the weaver gke command:

$ go install github.com/ServiceWeaver/weaver-gke/cmd/weaver-gke@lates

Service Weaver Components

With Service Weaver, an applications is composed of a set of components. This includes the component that implements the weaver.Main interface, and other components needed by weaver.Main for composing the system. The modular binary will be created from the component that implements the weaver.Main interface.

The demo application is composed by using the following components:

  • orderservice: Implements the weaver.Main interface. This component is used for running an HTTP server for placing Order.
  • paymentservice: This component makes the payment transaction for each Order.
  • notificationservice: This component sends notifications after the Order processing is completed.

The code block below provides the component interface and its corresponding implementation for paymentservice:

Listing 1. Component interface and its implementation for

package paymentservice

import (
"context"

"github.com/ServiceWeaver/weaver"

"github.com/shijuvar/service-weaver/orderapp/model"
)

type Service interface {
MakePayment(ctx context.Context, orderPayment model.OrderPayment) error
}

type implementation struct {
weaver.Implements[Service]
}

func (s *implementation) MakePayment(ctx context.Context, orderPayment model.OrderPayment) error {
defer s.Logger().Info(
"payment has been processed",
"order ID", orderPayment.OrderID,
"customer ID", orderPayment.CustomerID,
"amount", orderPayment.Amount,
)
// ToDO: make the payment
return nil
}

In the preceding code block, we define an interface named Service to be modelled as paymentservice component and provides a concrete implementation in the struct named implementation by embedding the generic type weaver.Implements[T].

Similar like paymentservice, we define an interface named Service to be modelled as notificationservice component and provides a concrete implementation in the struct named implementation for notificationservice in the Listing 2.

Listing 2. Component interface and its implementation for notificationservice

package notificationservice

import (
"context"

"github.com/ServiceWeaver/weaver"

"github.com/shijuvar/service-weaver/orderapp/model"
)

type Service interface {
Send(ctx context.Context, notification model.Notification) error
}

type implementation struct {
weaver.Implements[Service]
}

func (s *implementation) Send(ctx context.Context, notification model.Notification) error {
defer s.Logger().Info(
"notification has been sent",
"order ID", notification.OrderID,
"customer ID", notification.CustomerID,
"event", notification.Event,
"modes", notification.Modes,
)
// ToDO: send the notification
return nil
}

By embedding the generic type weaver.Implements[T], we create components in Service Weaver. The modular binary will be created from the main component that implements weaver.Implements[weaver.Main] type. Let’s write the component to embed weaver.Implements[weaver.Main] type, which is used to running the HTTP server for placing the Order, in the orderservice component.

Listing 3. Struct Server embedes weaver.Implements[weaver.Main] in the orderservice component

type Server struct {
weaver.Implements[weaver.Main]

handler http.Handler

paymentService weaver.Ref[paymentservice.Service]
notificationService weaver.Ref[notificationservice.Service]

orderapi weaver.Listener `weaver:"orderapi"`
}

Making reference with other components

The components other than the weaver.Main component, transitively needed by weaver.Main. Any kind of component can make reference with other components regardless whether it’s a weaver.Main component or not. In Listing 3, the struct Server that represents orderservice component, is needed paymentservice component to make the payment transaction, and notificationservice component to send notifications. One Service Weaver component can call methods of other component by making a reference with weaver.Ref[T] where T is the interface of the component to which you want to make reference. Here we want to make reference with paymentservice and notificationservice so that we make properties in the struct Server.

paymentService      weaver.Ref[paymentservice.Service]
notificationService weaver.Ref[notificationservice.Service]

The orderservice component allows network traffic by exposing an HTTP server so that we keep property handler of type http.Handler to create an HTTP server.

handler http.Handler

Network Listeners for network traffic

A Service Weaver component implementation may use network listeners. For example, the orderservice component needs to serve HTTP network traffic. In order to do this, the implementation struct must be provided a property with type weaver.Listener.

 orderapi weaver.Listener `weaver:"orderapi"`

The weaver.Listener fields can read listener address from a .toml configuration file. The struct tag `weaver:”orderapi”` read listener address from a configuration file named weaver.toml. Service Weaver uses .toml file for configuration.

Listing 4. weaver.toml configuration file

[serviceweaver]
binary = "./orderapp"

[single]
listeners.orderapi = {address = "localhost:3000"}

[multi]
listeners.orderapi = {address = "localhost:3000"}

[gke]
regions = ["us-west1"]
listeners.orderapi = {public_hostname = "orderapp.example.com"}

Method Init to initialise components

Service Weaver componens can optionally provide an Init method in the structs of component implementation. If there is any Init method, this will be invoked when Service Weaver automatically creates the instances of components. Here just for the sake of example, an Init method is provided for the Server struct in the main component, which creates a go-chi router (github.com/go-chi/chi) instance and assign to the handler property of the Server struct as shown in the Listing 5.

Listing 5. Init method of the Server struct

func (s *Server) Init(context.Context) error {
s.Logger().Info("Init")
r := chi.NewRouter()
r.Route("/api/orders", func(r chi.Router) {
r.Post("/", s.CreateOrder)
r.Get("/{orderid}", s.GetOrderByID)
})
s.handler = r
return nil
}

Serving the Service Weaver application

A Service Weaver application is running by calling the function weaver.Run, which requires a function value as a parameter

Let’s write a serve function with appropriate signature to be used for the function weaver.Run to run a Service Weaver application.

Listing 6. Serve function with the signature of func(context.Context, *T) error

func Serve(ctx context.Context, s *Server) error {
s.Logger().Info("OrderAPI listener available.", "addr:", s.orderapi)
httpServer := &http.Server{
Handler: s.handler,
}
httpServer.Serve(s.orderapi)
return nil
}

In the Serve function , we create an HTTP server and serving it by using the weaver.Listener property.

Here’s the complete implementation of the Server struct (main component):

Listing 7. Implementation of the main component

package orderservice

import (
"context"
"net/http"

"github.com/ServiceWeaver/weaver"
chi "github.com/go-chi/chi/v5"

"github.com/shijuvar/service-weaver/orderapp/notificationservice"
"github.com/shijuvar/service-weaver/orderapp/paymentservice"
)

type Server struct {
weaver.Implements[weaver.Main]

handler http.Handler

paymentService weaver.Ref[paymentservice.Service]
notificationService weaver.Ref[notificationservice.Service]

orderapi weaver.Listener `weaver:"orderapi"`
}

func (s *Server) Init(context.Context) error {
s.Logger().Info("Init")
r := chi.NewRouter()
r.Route("/api/orders", func(r chi.Router) {
r.Post("/", s.CreateOrder)
r.Get("/{orderid}", s.GetOrderByID)
})
s.handler = r
return nil
}

// Serve implements the application main.
func Serve(ctx context.Context, s *Server) error {
s.Logger().Info("OrderAPI listener available.", "addr:", s.orderapi)
httpServer := &http.Server{
Handler: s.handler,
}
httpServer.Serve(s.orderapi)
return nil
}

Calling the methods of referenced components

In the Server struct, components paymentservice and notificationservice are referenced by using weaver.Ref[T] where T is the interface of the components.

paymentService      weaver.Ref[paymentservice.Service]
notificationService weaver.Ref[notificationservice.Service]

When Service Weaver creates an instance of the component, it will automatically create the referenced components (weaver.Ref[T] field) as well. In order to get the components instance from the weaver.Ref[T] field, to invoke the methods of the components, just call the method Get on the weaver.Ref[T] field. For example, the code block below gets the paymentService component:

Listing 8. Getting the paymentService component

paymentService:=s.paymentService.Get() // Get returns paymentservice component

In this example demo, when an Order is placing, paymentService component is used for making the payment and notificationservice is used for sending notifications. This is implemented in the HTTP handler code, which is used for handling HTTP Post requests for placing the Order.

Listing 8. HTTP Handler code that calls methods of referenced components

package orderservice

import (
"context"
"encoding/json"
"net/http"
"time"

chi "github.com/go-chi/chi/v5"
"github.com/google/uuid"

"github.com/shijuvar/service-weaver/orderapp/model"
)

var ctx = context.Background()

func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) {
var order model.Order
err := json.NewDecoder(r.Body).Decode(&order)
if err != nil {
http.Error(w, "Invalid Order Data", 500)
return
}
id, _ := uuid.NewUUID()
aggregateID := id.String()
order.ID = aggregateID
order.Status = "Placed"
order.CreatedOn = time.Now()
order.Amount = order.GetAmount()
orderPayment := model.OrderPayment{
OrderID: order.ID,
CustomerID: order.CustomerID,
Amount: order.Amount,
}
// make payment using paymentservice component
if err := s.paymentService.Get().MakePayment(ctx, orderPayment); err != nil {
s.Logger().Error(
"payment failed",
"error:", err,
)
http.Error(w, "payment failed", http.StatusInternalServerError)
return
}
notification := model.Notification{
OrderID: order.ID,
CustomerID: order.CustomerID,
Event: "order.placed",
Modes: []string{model.Email, model.SMS},
}
// send notification using notificationService component
if err := s.notificationService.Get().Send(ctx, notification); err != nil {
s.Logger().Error(
"notification failed",
"error:", err,
)
}
s.Logger().Info(
"order has been placed",
"order id", order.ID,
"customer id", order.CustomerID,
)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
}

In the preceding code block, referenced components are created by calling method Get on the weaver.Ref[T] field, and invoking methods of components.

// make payment using paymentservice component
if err := s.paymentService.Get().MakePayment(ctx, orderPayment); err != nil {
}

// send notification using notificationService component
if err := s.notificationService.Get().Send(ctx, notification); err != nil {
}

Generating the code with weaver generate command

Whenever you make some implementation that uses the Service Weaver package (github.com/ServiceWeaver/weaver), you should run the weaver generate command before making the build (go build) or running the app with go run. This will generate some code with a file named weaver_gen.go.

The command below generates code for the current directory

weaver generate .

The command below generates code for the current directory and its all sub directories:

weaver generate ./...

Serializable Types for invoking methods of components

When you invoke methods of Service Weaver components, the arguments and return type of the methods must be serialized in order to sent over the network. The struct type can be made as serialisable by embedding the type weaver.AutoMarshal.

Listing 9. Struct types make it as serialisable

package model

import (
"github.com/ServiceWeaver/weaver"
)

const (
Email string = "email"
SMS string = "sms"
)

type OrderPayment struct {
weaver.AutoMarshal
OrderID string
CustomerID string
Amount float64
}

type Notification struct {
weaver.AutoMarshal
OrderID string
CustomerID string
Event string
Modes []string
}

Running the Service Weaver application

The Run function of the weaver (github.com/ServiceWeaver/weaver) package runs weaver.Main component as a Service Weaver application.

The code block below runs the Service Weaver application:

Listing 10. Runs the Service Weaver application

package main

import (
"context"
"log"

"github.com/ServiceWeaver/weaver"

"github.com/shijuvar/service-weaver/orderapp/orderservice"
)

func main() {
if err := weaver.Run(context.Background(), orderservice.Serve); err != nil {
log.Fatal(err)
}
}

In the preceding code block, orderservice.Serve is a function value that defined with the signature of func(context.Context, *T) error where T is the struct implementation of the main component. The function weaver.Run automatically create an instance of the main component that implements weaver.Main. If there is any Init method provided in the component implementation, that will be used for creating the instances.

Running the application in single process

Before compiling the code or running the application with go run command, make ensure that weaver generate command is executed to generate the code, which is essential for Service Weaver application.

The command below runs the application with a configuration file weaver.toml:

SERVICEWEAVER_CONFIG=weaver.toml go run .

The application is running locally with listener address “127.0.0.1:3000”

Figure 1. The application is running locally

The figure below shows the log messages after placing an Order:

Figure 2. Log messages after placing an Order

The command below shows the status of the Service Weaver application:

weaver single status

The status shows every deployment, component, and listener as shown in the below figure:

Figure 3. List deployment, component, and listener

You can also run weaver single dashboard to open a dashboard in a web browser.

Running the application in multiple processes

Service Weaver applications lets your run in single process as a monolith and in multiple processes. In order to run in multiprocess execution, do compile the code with go build. Then, the command below with the configuration file weaver.toml runs the application in multiple processes:

weaver multi deploy weaver.toml 

Figure 4. Application is running in multiple processes

The command below shows the status of the Service Weaver application in multiple processes:

weaver multi status

Figure 5. List deployment, component, and listener in
multiprocess execution

You can also run weaver multi dashboard to open a dashboard in a web browser.

Service Weaver applications can run locally in a single process with go run command and across multiple processes with the command weaver multi deployso that you can easily debug and test your applications in your deveopment environment.

Deploying Service Weaver application to the Cloud

When your application is ready for production, you can deploy into various environments. The command below lets you deploy the application into Google Cloud’s Google Kubernetes Engine:

weaver gke deploy weaver.toml

For more details on deploying Service Weaver application into Google Kubernetes Engine, check out the documentation at: https://serviceweaver.dev/docs.html#gke

Source Code

The source code of the example is available on github at: https://github.com/shijuvar/service-weaver/tree/main/orderapp

Summary

When you build modern applications, you may typically go either with monolith or microservices-based architecture, where logical boundaries are tightly coupled with physical boundaries. Microservices architetcure combines logical boundaries where how code is written with physical boundaries where how code is deployed. This makes tight coupling between your logical separation of code with the deployment infrastructure.

Service Weaver brings the idea of developing your application as a modular monolith, which can be run as a modular binary in single process, and also allows you to run in multiple processes. It doesn’t tightly coupled how code is written with how code is deployed. This enables the best of both Monolith and Microservices.

You can follow me on LinkedIn. In India, I do provide architecture consulting and training for building distributed systems in the Go programming language. My distributed systems programming Masterclass, includes the guidance for building distributed applications with Service Weaver.

--

--

Shiju Varghese

Consulting Solutions Architect and Trainer on Go and Distributed Systems, with focus on Microservices and Event-Driven Architectures. Author of two books on Go.