Architecting the Application
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 main
creating 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.
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
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
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:
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.
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.
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.
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
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
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()
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
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.
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.
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.
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
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
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
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
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:
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
- Application - GitHub
- Previous Article: Config Chronicles