17 min read

Architecting the Application

Building our dependency tree directly in main? Not bad, but there's a better way. Dive into this chapter to discover how.
Efficiently handling tasks, our workers in action

Welcome again to a new installment in our Go web development series, where we continue our journey from zero to production with Plykan, our Kanban Board project. With each passing chapter, our project takes shape, evolving into a robust web application. In today's article, we'll delve into an important aspect of our architecture: application abstraction.

Until now, we've been diligently assembling our project's dependency tree directly in the main.go file, within the maincreating function itself. While this approach is common practice and functional, we're keen on exploring a more organized way to manage our service's dependencies. Why, you may ask? There are several compelling reasons. First and foremost, it enhances the neatness of our codebase, as we can encapsulate all our dependencies within a dedicated entity responsible for their management. This entity can gracefully handle resource restoration in case of failures, such as database connections or service endpoints. Moreover, if our design proves successful and we wish to replicate it in future projects, having a dedicated entity makes it simpler to extract common functionality, potentially leading to the creation of a framework, a library, or even a code generator.

Our journey into application abstraction doesn't stop with dependency management. We'll also explore the role of models that beyond serving as the foundation for services, models house a portion of our business logic. We've already introduced some drafts of them in our controllers, but it's high time we find a more suitable home for them. We'll identify shared functionalities among these models and those we'll develop in the future to eliminate redundancy.

Pre-Setup

As highlighted in the introduction, there may come a time when we want to extract common functionality to generate a foundation for other projects. However, even if we don't have immediate plans for such endeavors, it's wise to keep our business logic separate from the invariant code that's common to any web application developed under the same principles. Our goal is to declutter our business space by isolating elements that can be generalized and extracted.

The task at hand is relatively straightforward. For now, we'll relocate the log and config packages to a dedicated /internal/sys directory within our project. It's worth noting that in Go, package nesting doesn't carry the same implications as it might in other programming languages—it doesn't create any hierarchy or scope. It's best to avoid excessive nesting as it can complicate code without adding benefits and isn't idiomatic in Go. However, in this particular case, we'll make a temporary exception driven by our intention to eventually put common functionality into a separate module.

Log and Config

Let's establish an internal/sys directory within our project structure. Inside this directory, we'll relocate the log and config packages that we've already developed. If we're not using an IDE with refactoring capabilities, it's essential to remember to manually update references to these packages in the places where they are used.

Common Features

As we go deeper into our application, a recurring pattern emerges among our entities. They all share common requirement, they need to be configurable, they rely on logging services, and when they log information, it's essential to distinguish them amidst a sea of messages. To address these shared needs, it's prudent to provide each entity with a unique identifier, a name.

Rather than replicating this logic in every individual entity, we can take advantage of Go's concept of embedding to generalize these common features. In the next section, We are going to get to work to do this.

Core

Interface

Let's start defining some interfaces.

type (
	Core interface {
		Name() string
		Log() log.Logger
		Cfg() config.Config
		Lifecycle
	}

	Lifecycle interface {
		Setup(ctx context.Context) error
		Start(ctx context.Context) error
		Stop(ctx context.Context) error
	}
)

/internal/sys/core.go

Core

This interface serves as a blueprint for entities within our application. It encapsulates generic characteristics that various entities, regardless of their specific layer (repositories, databases, message queues, models, controllers), must adhere to for the seamless operation of our application. These shared characteristics include providing a name for identification, access to a logging service for message management, and access to the application's configuration settings. Additionally, the Core interface incorporates the Lifecycle interface, which defines methods responsible for the setup, starting, and stopping of these entities.

Lifecycle

This interface outlines the lifecycle management methods that entities should implement. These methods include Setup, which initializes an entity within the context of the application, Start, which initiates the entity's functionality, and Stop, which gracefully terminates the entity's operations. By including these lifecycle management methods we ensure that various entities can be seamlessly integrated into our application with consistent setup and shutdown procedures. This facilitates the graceful handling of potential errors during the time of these entities are operation.

Implementation

type (
	// SimpleCore is a simple implementation of the Core interface
	SimpleCore struct {
		name string
		log  log.Logger
		cfg  config.Config
	}
)

// ...

// Name returns the name of the core
func (sc *SimpleCore) Name() string {
	return sc.name
}

// Log returns the logger of the core
func (sc *SimpleCore) Log() log.Logger {
	return sc.log
}

// Cfg returns the config of the core
func (sc *SimpleCore) Cfg() config.Config {
	return sc.cfg
}

/internal/sys/core.go

The SimpleCore struct is a concrete implementation of the Core interface. It encapsulates essential attributes such as a name for identification, a logging service, and a configuration object. By implementing the Name(), Log(), and Cfg() methods, SimpleCore provides specific implementations for the corresponding interface methods.

Constructor

func NewCore(name string, opts ...Option) *SimpleCore {  
    name = GenName(name, "core")  
  
    bw := &SimpleCore{  
       name: name,  
    }  
  
    for _, opt := range opts {  
       opt(bw)  
    }  
  
    return bw  
}

/internal/sys/core.go

To initialize a SimpleCore instance, we provide a constructor function named NewCore. While we pass the name as a required parameter, you might have noticed that we don't explicitly pass the logger and configuration objects during construction. Instead, we employ the Options pattern to enable flexible and extensible initialization.

The Options pattern provides us with the flexibility to customize the initialization of our SimpleCore instances by offering additional options, all without the need to modify the constructor's signature. This means that if we ever require one or more generic dependencies for our application entities in the future, the process of incorporating these changes will be straightforward, primarily concerning their initial instantiation.

In the NewCore function, we start by generating a unique name for our SimpleCore instance using the GenName function. Then, we create a new SimpleCore with the given name. Here's where the magic of the Options pattern comes into play. The opts ...Option argument is a variadic parameter that allows us to pass zero or more Option functions when calling the constructor.

The Option type is defined as a functional type, and we provide two pre-defined options: WithConfig and WithLogger. These options are functions that accept a SimpleCore instance and apply specific configurations, such as setting the configuration object or logger. By iterating through the provided options and invoking them with our SimpleCore instance as an argument, we customize the SimpleCore based on the provided options.

The beauty of this approach is that it allows us to add additional initialization possibilities to our entities without the need to modify the constructor's signature. Each new option function can provide a unique way to configure theSimpleCore, enhancing its versatility and adaptability to different scenarios. This results in cleaner, more flexible code that can evolve as our application's requirements change.

In order to make it clear how everything works, let's take a closer look at the Options pattern. This pattern allows you to customize the initialization of SimpleCore instances, enhancing their flexibility and adaptability. Here's how it works in practice:

type (
    Option func(w *SimpleCore)
)

func WithConfig(cfg *config.Config) Option {
    return func(sc *SimpleCore) {
        sc.SetCfg(cfg)
    }
}

func WithLogger(log log.Logger) Option {
    return func(sc *SimpleCore) {
        sc.SetLog(log)
    }
}

/internal/sys/core.go

In the code snippet above, we define two options: WithConfig and WithLogger. Each option is a function that takes a SimpleCore instance and applies specific configurations, such as setting the configuration object or logger.

func GenName(name, defName string) string {  
    if strings.Trim(name, " ") == "" {  
       return fmt.Sprintf("%s-%s", defName, nameSufix())  
    }  
    return name  
}  
  
func nameSufix() string {  
    digest := hash(time.Now().String())  
    return digest[len(digest)-8:]  
}  
  
func hash(s string) string {  
    h := fnv.New32a()  
    h.Write([]byte(s))  
    return fmt.Sprintf("%d", h.Sum32())  
}

/internal/sys/core.go

When using the NewCore constructor, you have the option to provide a name or leave it empty. If you provide a name, such as user-repo it will be directly used as the name for the SimpleCore instance, as shown below:

// coreWithName.name will be 'user-repo'
coreWithName := NewCore("user-repo") 

However, if you provide an empty string as the name, the provided functions come into play to generate a unique identifier. These functions collectively ensure that entities receive distinct and identifiable names, even when no explicit name is given.

In practice, when you create a SimpleCore instance with an empty string as the name, the result will resemble the following.

// coreWithEmptyName.name will be something like 'core-18793317'
coreWithEmptyName := NewCore("")

Lifecycle

The Lifecycle interface defines the fundamental lifecycle management methods that entities within our application should adhere to. These methods include Setup for initialization, Start for activation, and Stop for graceful termination.

// Setup sets up the core
func (sc *SimpleCore) Setup(ctx context.Context) {
    sc.Log().Infof("%s setup", sc.Name())
}

// Start starts the core
func (sc *SimpleCore) Start(ctx context.Context) error {
    sc.Log().Infof("%s start", sc.Name())
    return nil
}

// Stop stops the core
func (sc *SimpleCore) Stop(ctx context.Context) error {
    sc.Log().Infof("%s stop", sc.Name())
    return nil
}

/internal/sys/core.go

While the SimpleCore provides a basic implementation with logging, entities using this core will eventually override these methods with custom implementations when required, enabling them to manage their lifecycles in a way that suits their specific functionality and needs.

Application

Let's start with this simple structure.

type App struct {  
    sys.Core  
    opts    []sys.Option  
    web     *web.Server  
}  

/internal/app/app.go

Pretty simple, right? Not too much to explain, for now, just a straightforward container for managing core application functionality. Additionally, we are obligated to satisfy the sys.Core interface. While we currently observe the web.Server for handling web-related tasks, in the future, all dependencies will be attached to this entity.

// NewApp creates a new app instance
func NewApp(name string, log log.Logger) (app *App, err error) {
    cfg, err := config.NewConfig(name).Load()
    if err != nil {
        return nil, fmt.Errorf("failed to create new app: %w", err)
    }

    opts := []sys.Option{
        sys.WithConfig(cfg),
        sys.WithLogger(log),
    }

    app = &App{
        Core: sys.NewCore(name, opts...),
        opts: opts,
    }

    return app, nil
}

/internal/app/app.go

NewApp function serves as the entry point for creating a new instance of our application. It takes two parameters: name, representing the application's name, and log, a logger used for recording application events. Inside the function, we initialize the application's configuration, handle potential errors gracefully, set up options for core components such as configuration and logging, and ultimately return a fully prepared app instance. As simple as that.

Embedding sys.Core in Current Entities

Now, entities like the Router and the Server can take full advantage of the capabilities provided by sys.Core. By embedding sys.SimpleCore within these entities, we've streamlined our codebase, allowing us to eliminate redundant properties and functions related to logging, configuration, and entity identification.

For a detailed view of these modifications and how they've simplified our code, you can refer to the updated code snippets here and here. Notably, we've removed properties and functions that would become redundant when incorporating SimpleCore. Additionally, the constructors for these entities have been adjusted to accept ...sys.Option, mirroring the pattern we've seen with the App entity. While we won't delve into the specifics here, due to the potential diversion from our current topic, these changes, which we've already explained in the context of the App entity, signify a significant step in enhancing the modularity of our application.

Run

// Run runs the app
func (app *App) Run() (err error) {
    ctx := context.Background()

    err = app.Setup(ctx)
    if err != nil {
        return fmt.Errorf("failed to run %s: %w", app.Name(), err)
    }

    return app.Start(ctx)
}

/internal/app/app.go

The Run() function is the application started. When called from the main function, it handles the essential tasks of setting up all dependencies and starting them.

Setup

// Setup sets up the app
func (app *App) Setup(ctx context.Context) error {
    app.web = web.NewServer("web-server", app.opts...)
    app.web.Setup(ctx)
    return nil
}

/internal/app/app.go

Setup() plays a role in our application's initialization process. This function overrides the embedded Setup() method from the basesys.SimpleCore implementation, allowing us to tailor the setup process to our specific needs.

In this implementation, we create a new instance of the web.Server using the new web.NewServer("web-server", app.opts...) constructor. Next, we invoke the Setup() method of the web.Server to perform any necessary setup tasks for the web component. This ensures that our web-related dependencies are properly prepared for use within our application.

Start

// Start starts the app
func (app *App) Start(ctx context.Context) error {
    app.Log().Infof("%s starting...", app.Name())
    defer app.Log().Infof("%s stopped", app.Name())

    err := app.web.Start(ctx)
    app.Log().Infof("%s started!", app.Name())

    return err
}

/internal/app/app.go

The Start() function is responsible for initiating our application and bringing it to life. It also overwrites the same function from the embedded sys.SimpleCore.

We use defer to ensure that another message is logged when the function exits, indicating that the application has stopped. We then proceed to start web.Server by invoking its Start() method, which activates our web-related components. Throughout the process, we use logging to keep track of important events.

We are not going to delve into the implementation of the func (app *App) Stop(ctx context.Context) error method at this point. However, it's worth noting that in the future, this function will play a role in gracefully shutting down each of our application's dependencies.

Server

In our pursuit of a more organized and abstracted codebase, the Server structure has undergone some improvements. The updated structure now leverages the capabilities provided by sys.Core, streamlining the code and eliminating unnecessary properties. Here's the updated code snippet.

type Server struct {
    sys.Core
    opts   []sys.Option
    http.Server
    router *Router
    bc     *controller.BoardController
}

func NewServer(name string, opts ...sys.Option) *Server {
    return &Server{
        Core:   sys.NewCore(name, opts...),
        opts:   opts,
        router: NewRouter("web-router", opts...),
    }
}

/internal/web/server.go

The opts []sys.Option, which currently resides in Server, may find a home in sys.SimpleCore in the future. As part of its setup lifecycle, Server initializes routes by calling setupRoutes(). In this process, we've added a handler for the BoardController. Note that the BoardController now will need to implement specific interface methods to behave like a net/http handler. We'll delve into these details shortly.

Board Controller

We've also made noteworthy changes to the BoardController structure. Previously, it included properties related to name, logging, and configuration. However, with the introduction of sys.Core, we were able to remove these redundant properties and functions.

// BoardController is responsible for handling board-related operations.
type BoardController struct {
    sys.Core
}

// NewBoardController creates a new BoardController instance.
func NewBoardController(opts ...sys.Option) *BoardController {
    return &BoardController{
        Core: sys.NewCore("board-controller", opts...),
    }
}

/internal/controller/board.go

We refined the BoardController to adhere to the net/http interface's ServeHTTP method.This interface is essential for handling HTTP requests and responses within Go's standard library.

The ServeHTTP method begins by creating a mux.Router. Inside this router, we define our well-known routes for board-related operations, such as retrieving boards, creating new boards, updating boards, and more.

func (bc *BoardController) ServeHTTP(w http.ResponseWriter, r *http.Request) {  
    router := mux.NewRouter()  
  
    // Board routes  
    router.HandleFunc("/boards", bc.BoardsIndex).Methods("GET")  
    router.HandleFunc("/boards/new", bc.NewBoard).Methods("GET")  
    router.HandleFunc("/boards", bc.CreateBoard).Methods("POST")  
    router.HandleFunc("/boards/{id}", bc.ShowBoard).Methods("GET")  
    router.HandleFunc("/boards/{id}/edit", bc.EditBoard).Methods("GET") 
    router.HandleFunc("/boards/{id}", bc.UpdateBoard).Methods("PUT")  
    router.HandleFunc("/boards/{id}", bc.DeleteBoard).Methods("DELETE") 
    router.HandleFunc("/boards/{id}/confirm-delete", bc.DeleteConfirm).Methods("GET")  
  
    router.ServeHTTP(w, r)  
}

/internal/controller/board.go

The ServeHTTP method is a part of the http.Handler interface definition, and it's essential for defining how an object responds to HTTP requests. When a type implements this http.Handler interface function by defining a ServeHTTP method, it becomes capable of handling incoming HTTP requests. In essence, it's a handy way to transform a struct into a valid HTTP handler.

Models

package models

type BoardData struct {
    ID         string       // Unique board identifier
    ToDo       []string     // List of tasks for the "To Do" column
    InProgress []string     // List of tasks for the "In Progress" column
    Done       []string     // List of tasks for the "Done" column
    Columns    []ColumnData // List of Kanban columns
}

type Task struct {
    ID     string
    Name   string
    Detail string
}

type ColumnData struct {
    ColumnName string   // E.g., "To Do," "In Progress," "Done"
    Tasks      []string // List of tasks for the column
    Color      string   // Background color class, e.g., "blue-200"
}

/internal/model/model.go

Considering our application's long-term goals and requirements, several critical considerations come to the forefront. Firstly, there's the imperative need for persistence, enabling data storage and retrieval. Additionally, we recognize that all entities within our application will share common characteristics: they'll require a unique identifier (ID) for clear differentiation, a slug for external identification without exposing internal implementation details, and a tenant identifier to facilitate the development of a multi-tenant system, even if it's not an immediate requirement for our Kanban board application.

In the context of Go, a language that embraces composition over traditional classes and inheritance, we rely on the principle of composition to fulfill these requirements. To encapsulate these common functionalities, we employ embedded structs, much like we did for our dependencies with sys.SimpleCore.

However, rather than embedding these models directly within our business objects located in the internal/model package, we opt to encapsulate these common attributes and functionalities within a dedicated internal/sys/model package. This strategic decision recognizes their potential for reusability and relevance beyond the boundaries of our specific application domain. This choice aligns with our mission to distill best practices and patterns for broader application development, emphasizing flexibility while addressing diverse requirements across various projects.

It's important to emphasize that this choice isn't solely intended to address the immediate needs of our Kanban board application; it's part of a broader initiative to distill best practices and patterns for commercial application development. Our ultimate goal is to create a versatile framework that streamlines and automates the development process.

ID

package model  
  
import (  
    "fmt"  
    "strings"    "unicode/utf8"  
    "github.com/google/uuid")  
  
type (  
    ID struct {  
       val      uuid.UUID  
       TenantID uuid.UUID  
       Slug     string  
    }  
)  
  
func NewID(uid ...uuid.UUID) ID {  
    if len(uid) > 0 {  
       return ID{val: uid[0]}  
    }  
    return ID{val: uuid.New()}  
}
  
func (id *ID) GenID(provided ...uuid.UUID) {  
    if id.val != uuid.Nil {  
       return // A value has been previously assigned  
    }  
  
    if len(provided) > 0 {  
       id.val = provided[0] // If value is provided, use it  
       return  
    }  
  
    id.val = uuid.New()  
}  
  
func (id *ID) Val() uuid.UUID {  
    return id.val  
}  
  
func (id *ID) String() string {  
    return id.val.String()  
}  
  
func (id *ID) IsNew() bool {  
    return id.Val() == ZeroUUID  
}  
  
func (id *ID) UpdateSlug(prefix string) (slug string) {  
    if strings.Trim(id.Slug, " ") == "" {  
       s, err := id.genSlug(prefix)  
       if err != nil {  
          return ""  
       }  
  
       id.Slug = s  
    }  
  
    return id.Slug  
}  
  
func (id *ID) genSlug(prefix string) (slug string, err error) {  
    if strings.TrimSpace(prefix) == "" {  
       return "", NoSlugPrefixErr  
    }  
  
    prefix = strings.Replace(prefix, "-", "", -1)  
    prefix = strings.Replace(prefix, "_", "", -1)  
  
    if !utf8.ValidString(prefix) {  
       v := make([]rune, 0, len(prefix))  
       for i, r := range prefix {  
          if r == utf8.RuneError {  
             _, size := utf8.DecodeRuneInString(prefix[i:])  
             if size == 1 {  
                continue  
             }  
          }          v = append(v, r)  
       }  
       prefix = string(v)  
    }  
  
    prefix = strings.ToLower(prefix)  
  
    s := strings.Split(uuid.New().String(), "-")  
    l := s[len(s)-1]  
  
    return strings.ToLower(fmt.Sprintf("%s-%s", prefix, l)), nil  
}  
  
func (id *ID) SetCreateValues(slugPrefix string) {  
    id.GenID()  
    id.UpdateSlug(slugPrefix)  
}  
  
func GenTag() string {  
    s := strings.Split(uuid.New().String(), "-")  
    l := s[len(s)-1]  
  
    return strings.ToUpper(l[4:12])  
}  

func (id *ID) Match(tc ID) (ok bool) {
	ok = id.Val() == tc.Val() &&
		id.TenantID == tc.TenantID &&
		id.Slug == tc.Slug
	return ok
}

/internal/sys/model/id.go

Here, we introduce the concept of an embeddable ID within the model package, which serves as a foundation for uniquely identifying various entities within our application. Let's explore the relevant code:

The ID struct includes fields for the unique value (val), a TenantID to support multi-tenancy, and a Slug for external identification. It forms the basis for all entities within our application.

NewID

Creates a new instance of an ID. It offers the flexibility to initialize the ID with a provided UUID when necessary. This capability is valuable for scenarios where certain entities require manual ID assignment, allowing for precise control over the unique identifier from the business logic side.

GenID

The GenID method generates a new UUID for the ID if it hasn't been assigned previously. You can provide a UUID value as an argument, which will be used if provided, or a new UUID will be generated.

Val

Returns the UUID value associated with the ID.

String

Converts the ID to its string representation, which is the UUID as a string.

IsNew

The IsNew method checks if the `ID` is new, meaning its value is equal to a predefined "zero" UUID.

UpdateSlug

Updates the `Slug` field of the `ID`. If the `Slug` is empty, it generates a new slug based on the provided prefix.

SetCreateValues

This method is a convenience function that generates a new ID, if needed, and updates the Slug based on a provided prefix.

GenTag

Generates a tag based on the ID. It can be used for various purposes, such as creating short, unique identifiers.

Match

The Match method compares two ID instances for equality. It checks if the Val, TenantID, and Slug fields of the two `ID` instances match.

Audit

package model

import (
    "time"

    "github.com/google/uuid"
)

// Audit captures information related to entity transactions, such as creation, update, and deletion.
type Audit struct {
    CreatedByID uuid.UUID // User ID of the creator
    UpdatedByID uuid.UUID // User ID of the updater
    DeletedByID uuid.UUID // User ID of the deleter
    CreatedAt   time.Time // Timestamp of creation
    UpdatedAt   time.Time // Timestamp of update
    DeletedAt   time.Time // Timestamp of deletion
}

// SetCreateValues sets the creation-related audit values.
func (a *Audit) SetCreateValues(userID ...uuid.UUID) {
    if len(userID) > 0 {
        a.CreatedByID = userID[0]
    }

    now := time.Now()
    a.CreatedAt = now
}

// SetUpdateValues sets the update-related audit values.
func (a *Audit) SetUpdateValues(userID ...uuid.UUID) {
    if len(userID) > 0 {
        a.UpdatedByID = userID[0]
    }

    now := time.Now()
    a.UpdatedAt = now
}

// SetDeleteValues sets the deletion-related audit values.
func (a *Audit) SetDeleteValues(userID ...uuid.UUID) {
    if len(userID) > 0 {
        a.DeletedByID = userID[0]
    }

    now := time.Now()
    a.DeletedAt = now
}

internal/sys/model/audit.go

The Audit structure serves as a container for capturing information related to entity transactions, including creation, update, and deletion events. It features fields to record the User ID of the creator, updater, and deleter, along with timestamps for creation, update, and deletion.

To accommodate various use cases, the SetCreateValues, SetUpdateValues, and SetDeleteValues methods are designed to handle these audit values. They take an optional userID parameter, allowing flexibility in associating user identities with the corresponding actions. If a userID is provided, it populates the respective field, and if not, the method gracefully proceeds without breaking the entity transactions. This approach makes the audit trail optional, allowing for its inclusion when needed while avoiding unnecessary complexity when user IDs are unavailable.

Models

package model

import (
	m "github.com/solutioncrafting/plykan/internal/sys/model"
)

// Board represents a Kanban board entity.
type Board struct {
	m.ID                // Unique board identifier
	ToDo       []string // List of tasks for the "To Do" column
	InProgress []string // List of tasks for the "To Do" column
	Done       []string // List of tasks for the "Done" column:w
	Columns    []Column // List of Kanban columns
	m.Audit             // Embedding the audit trail for board
}

// NewBoard creates a new instance of the Board entity with a unique ID.
func NewBoard() *Board {
	return &Board{
		ID: m.NewID(),
	}
}

// Column represents a Kanban board column entity.
type Column struct {
	m.ID                // Unique column identifier
	ColumnName string   // E.g., "To Do," "In Progress," "Done"
	Tasks      []string // List of tasks for the column
	Color      string   // Background color class, e.g.: "blue-200"
	m.Audit             // Embedding the audit trail for column
}

// NewColumn creates a new instance of the Column entity with a unique ID.
func NewColumn() *Column {
	return &Column{
		ID: m.NewID(),
	}
}

// Task represents a Kanban board task entity.
type Task struct {
	m.ID   // Unique task identifier
	Name   string
	Detail string
	m.Audit // Embedding the audit trail for task
}

// NewTask creates a new instance of the Task entity with a unique ID.
func NewTask() *Task {
	return &Task{
		ID: m.NewID(),
	}
}

/internal/model/model.go

We've replaced the simple string-based IDs for our entities with the newly introduced ID embeddable ID structs. This change allows us to include essential attributes like a unique UUID, a slug for external identification, and a tenant identifier as needed. Additionally, we've embedded the audit trail within these entities, enhancing their capabilities to track creation, updates, and deletions.

Of course, these updates will require modifications to the ShowBoard function to maintain its functionality as we transition to using the new embedded ID structs. These changes are necessary to ensure that the Page struct continues to work seamlessly with the updated model.Board structure, which now incorporates the new ID struct for identification purposes.

Here's the updated code for reference:

pageData := &Page{
    Head: HeadData{
        Title:       "Plykan - Your Personal Kanban Board",
        Description: "Achieve more, stress less",
    },
    Board: model.Board{
        ID: m.NewID(),
        // Populate data for each Kanban column
        Columns: []model.Column{
            {ID: m.NewID(), ColumnName: "To Do", Tasks: sampleToDoTasks, Color: "blue-200"},
            {ID: m.NewID(), ColumnName: "In Progress", Tasks: sampleInProgressTasks, Color: "green-200"},
            {ID: m.NewID(), ColumnName: "Done", Tasks: sampleDoneTasks, Color: "yellow-200"},
        },
    },
}

/internal/controller/board.go

While the ShowBoard function currently retains some prototype characteristics, we are using hardcoded data, it ensures the project compiles and runs as before. We understand that this function will evolve as we integrate database operations and retrieve boards dynamically.

Wrapping Up

In today's chapter, we embarked on a journey to refine and modularize our Kanban board application. Here's what we learned:

We introduced the App abstraction, a central orchestrator responsible for managing the lifecycle of our application's dependencies, bringing order to the chaos of our system.

We harnessed the power of Go's embedding concept to generalize and share features across our models.

We made strides towards encapsulating common functionalities in a dedicated package, emphasizing flexibility and reusability beyond the confines of our current project.

As we progress, we'll continue to explore and refine our application, seeking opportunities to enhance its architecture and capabilities. Stay tuned as we delve deeper into all this process of creating a our Kanban Board.

Cheers!

References