Go Echo Framework + DDD + CQRS: Part 1.1 [pkg]

Ivan Cherkasov
5 min readMay 18, 2024

--

This is a minor part of a series of articles in which I want to separately discuss the purpose of the pkg directory. In this article, we will look at how to organize the code in the pkg directory to configure the logger using the zap and viper libraries. We will create the package structure and implement an abstract factory to create different types of logger

Why a minor part?

This article complements the first one and helps offload the second part, as it is becoming quite extensive. Additionally, in this intermediate article, we will address the technical debt left after the first part.

A bit of theory

The pkg directory is not directly related to DDD and CQRS, but it plays an important role. This directory is intended for storing common code and libraries that can be reused in various parts of your project or even in other projects. By structuring the code this way, you can improve its modularity and reusability, which contributes to a cleaner and more maintainable architecture.

Example of implementing zap.Logger configured via Viper

In this section, we will look at how to create a customizable logger using the powerful capabilities of the zap library for logging and the viper library for configuration. The goal is to allow logger configuration through a configuration file or other configuration sources supported by viper.

Let’s add a configuration for our zap.Logger in config.dev.yaml:

zap:
cores:
console:
type: stream
level: info
encoding: console
time_encoder: iso8601
file:
type: "file"
encoding: json
time_encoder: iso8601
file:
path: "/var/log/myapp"
max_backups: 7
max_age: 30
max_size: 32

This configuration file sets up two core components for zap.Logger: console and file. The console core uses console-style formatting, while the file core saves logs in JSON format. The parameters time_encoder, level, max_backups, max_age, and max_size allow detailed customization of how and where the logs will be stored.

A core in zap is a central component that handles log writing. It receives messages from the logger, formats them, and sends them to the appropriate output channels (e.g., console, file, remote server, etc.). Each core can be configured for different logging levels and various output formats.

For more details, you can refer to the documentation on the package page: zapcore on pkg.go.dev.

Directory structure of pkg

In the pkg directory, we will now create the structure and files for our package.

pkg/
└── zap/
├── core/
│ ├── core.go # Abstract factory for creating `zapcore.Core`
│ ├── file.go # Implementation for file logging
│ └── stream.go # Implementation for stream output logging
└── logger.go # Logger constructor

core/core.go: Here, an abstract factory Core is defined, which creates the corresponding instance of zapcore.Core based on the configuration — either for file logging or stream output logging. This makes it easy to extend logging capabilities by adding new output types.

package core

import (
"fmt"

"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)

type Core interface {
Create(cfg *viper.Viper) (zapcore.Core, error)
}

func Create(cfg *viper.Viper, path string) (zapcore.Core, error) {
cfgCore := cfg.Sub(path)
if cfgCore == nil {
return nil, fmt.Errorf("core config at path '%s' not found", path)
}

cfgCore.SetDefault("type", TypeCoreStream)
cfgCore.SetDefault("level", "info")

var core Core
coreType := cfgCore.GetString("type")
switch coreType {
case TypeCoreStream:
core = &StreamCore{}
case TypeCoreFile:
core = &FileCore{}
default:
return nil, fmt.Errorf("unsupported core type: %s", coreType)
}

return core.Create(cfgCore)
}

core/stream.go and core/file.go: These files contain specific implementations of zapcore.Core for different logging methods. StreamCore is intended for logging to standard output or error streams, while FileCore configures logging to a file with rotation and size limit capabilities.

Example implementation for core/stream.go

package core

import (
"os"

"github.com/spf13/viper"
"go.uber.org/zap/zapcore"
)

type StreamCore struct{}

const TypeCoreStream = "stream"

func (c *StreamCore) Create(cfg *viper.Viper) (_ zapcore.Core, err error) {
var encoder zapcore.Encoder
if encoder, err = getEncoder(cfg); err != nil {
return nil, err
}

var level zapcore.LevelEnabler
if level, err = getLevel(cfg); err != nil {
return nil, err
}

return zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level), nil
}

logger.go: This file contains the package constructor, which initializes and returns an instance of zap.Logger configured according to the configuration obtained through Viper. This is the entry point for creating a customizable logger.

package zap

import (
"github.com/i4erkasov/go-ddd-cqrs/pkg/zap/core"
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)

func New(cfg *viper.Viper) (*zap.Logger, error) {
// Retrieve logger core configuration from Viper
cfgCore := cfg.Sub("zap.cores")
settings := cfgCore.AllSettings()
cores := make([]zapcore.Core, 0, len(settings))

for name := range settings {
c, err := core.Create(cfg, "zap.cores."+name)
if err != nil {
return nil, err
}

cores = append(cores, c)
}

// Add logger options
var opts []zap.Option
if cfg.IsSet("zap.development") && cfg.GetBool("zap.development") {
opts = append(opts, zap.Development())
}

if cfg.GetBool("zap.caller") {
opts = append(opts, zap.AddCaller())
}

if cfg.IsSet("zap.stacktrace") {
var level = zap.NewAtomicLevel()
if err := level.UnmarshalText([]byte(cfg.GetString("zap.stacktrace"))); err != nil {
return nil, err
}

opts = append(opts, zap.AddStacktrace(level))
}

return zap.New(zapcore.NewTee(cores...), opts...), nil
}

Technical Debt

In the previous article, we had temporary code for the logger in the http-server command to start our server:

bws := &zapcore.BufferedWriteSyncer{   
WS: os.Stderr,
Size: 512 * 1024,
FlushInterval: time.Minute,
}
defer bws.Stop()
consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
core := zapcore.NewCore(consoleEncoder, bws, zapcore.DebugLevel)
log := zap.New(core)

Now we can remove it and use our new implementation from the pkg package.

Here is how our corrected command will look:

package cli

import (
"github.com/i4erkasov/go-ddd-cqrs/internal/infrastructure/api/rest"
"github.com/i4erkasov/go-ddd-cqrs/pkg/zap"
"github.com/spf13/cobra"
zapLogger "go.uber.org/zap"
)

const HttpServerCommand = "http-server"
const VersionHttpServer = "1.0.0"

var httpServer = &cobra.Command{
Use: HttpServerCommand,
Short: "Start http server",
Version: VersionHttpServer,
RunE: func(cmd *cobra.Command, args []string) (err error) {
cfg = cfg.Sub("app")

var log *zapLogger.Logger
if log, err = zap.New(cfg); err != nil {
return err
}

var server *rest.Server
if server, err = rest.New(cfg.Sub("api.rest"), log); err != nil {
return err
}

return server.Start(cmd.Context())
},
}

func init() {
cmd.AddCommand(httpServer)
}

Conclusions and Next Steps

In this article, we have thoroughly examined how to use the pkg directory to create a customizable logger using the zap and viper libraries. We created the package structure, implemented an abstract factory to create different types of loggers, and addressed the technical debt left from the previous part. By discussing the organization of code in the pkg directory, we won’t need to delve into this topic in future articles, allowing us to focus on other important aspects of the Go Echo Framework, DDD, and CQRS.

The complete second part of the series is already on the way, where we will continue our deep dive into the Go Echo Framework, DDD, and CQRS.

You can find a more detailed implementation in the repository on GitHub:
https://github.com/i4erkasov/go-ddd-cqrs/tree/part-1.1

<< Part 1

--

--