DEV Community

Cover image for 03 - Application Architecture
Jacob Goodwin
Jacob Goodwin

Posted on

03 - Application Architecture

Last time we covered adding routes to our application. These routes serve as the entry point to our application from the outside world. This time around, we'll go over the layers of our application!

If you have any confusion about file structure or code, go to the Github repository and checkout the branch for this lesson!

If you prefer video, check out the video version below!

Application Architecture

A summary of the architecture is depicted below. This architecture is heavily influenced by go_clean_arch πŸ‘πŸΌπŸŽ‰. Each box represents an application layer.

Application Architecture

Incoming HTTP requests are parsed and validated by the handler layer. The handler layer calls the service layer's methods, which in turn accesses the repository, which uses data sources (persistence, data storage, etc.). Each layer depends on a "concrete implementation" of the layer to its right.

All of these layers can "work with" and pass models, defined in the model layer, to and from each other. These models hold the fundamental data properties, errors, and interfaces of the application. In some architectures, there may be a distinction between "domain models" and "data models," which will require methods for transforming the data models into domain models. I found that to be overkill for the current application, though this may be something to consider for your application.

Architecture Benefits

This architecture lends itself well to unit testing, though this is by no means the only such architecture. Each layer can define an expectation of what each layer to its right must "implement." Any actual, or "concrete implementation," of a layer must conform to these expectations. We define these expectations in Go, and many other languages, by defining interfaces. We can then test the application layers separately by "mocking" the responses from these interfaces.

As an example, we're going to create a user service. The service layer handles the application's logic. One functionality we need is to get a user's information, such as their name and email address. Therefore, the user service expects to be able to get a user from the database. This means that any repository that interacts with the user service should have a method for getting users (e.g., FindUserByID for getting a user based on their ID). We define the signature of this method as part of the UserRepository interface (example below).

Yet as far as the user service is concerned, it does not care how FindUserByID is implemented inside of the repository layer. The repository layer could implement this in numerous ways, and can even use different data sources (MySQL instead of Postgres, for example).

Some projects may be very strict about unit testing each layer, and others may "integrate" various layers. As an example, the handler and service layers may be tested together instead of separately, or even combined into a single layer. In my opinion, the most critical layer that should absolutely be unit tested is the service layer. In this application, we will not be creating interfaces for the data sources.

Now let's create our user model, and add our first interfaces and errors to to the model layer! Next time, we'll implement simplified versions of the various layers for getting a user's personal information.

Create model package

Let's add a model folder to hold our user model along with our application errors and interfaces. Inside of this folder, folder, create the following files.

Model Layer Files

user.go

Insider of user.go, we add a struct with the properties our user model will hold. We import the uuid library to define user IDs of type UUID (you may need to go get "github.com/google/uuid").

We also add struct tags between back ticks for each field. These fields provide a mapping for the name of the struct field in Go to the name of the field in the database and the name of the field in any JSON response. Note that the password uses a json value of "-". This prevents the password from being returned in any JSON response.

package model

import (
    "github.com/google/uuid"
)

// User defines domain model and its json and db representations
type User struct {
    UID      uuid.UUID `db:"uid" json:"uid"`
    Email    string    `db:"email" json:"email"`
    Password string    `db:"password" json:"-"`
    Name     string    `db:"name" json:"name"`
    ImageURL string    `db:"image_url" json:"imageUrl"`
    Website  string    `db:"website" json:"website"`
}
Enter fullscreen mode Exit fullscreen mode

interfaces.go

In this file, we define of our expectations for each layer. For now, we'll only add the interfaces that we'll use in the next tutorial. The handler will expect to access a method that either returns a User or an error. The same is true for the repository layer. While in this case there is a one-to-one correspondence in the signatures of the service and repository layers, this is not always the case. For example, a service might need to access multiple repositories in a single service method, or require access to other helper or utility functions, and apply logic to produce its response.

package model

import "github.com/google/uuid"

// UserService defines methods the handler layer expects
// any service it interacts with to implement
type UserService interface {
    Get(uid uuid.UUID) (*User, error)
}

// UserRepository defines methods the service layer expects
// any repository it interacts with to implement
type UserRepository interface {
    FindByID(uid uuid.UUID) (*User, error)
}
Enter fullscreen mode Exit fullscreen mode

errors.go

In this file, we do the following.

  1. Create a custom Error struct with JSON struct tags for sending JSON responses from our API with "type" and a "message" fields.
  2. Define all of the error types we expect to return (Authorization, BadRequest, ..., PayloadTooLarge). At the bottom of the file you'll see factories for creating each error type with a custom message. These factories can be called in the various application layers.
  3. An Error() receiver method. This allows our custom Error to implement the Golang error interface.
  4. A Status() receiver method which is used to get the HTTP status of the error as an int. This is useful when sending responses in handlers.
  5. A func Status(err error) int function. This allows us to determine if an error is a model.Error (i.e., the one we're defining right now). If it is, the function returns the HTTP Status code in 4. If not, it returns an Internal Service Error (500) status code.
package model

import (
    "errors"
    "fmt"
    "net/http"
)

// Type holds a type string and integer code for the error
type Type string

// "Set" of valid errorTypes
const (
    Authorization   Type = "AUTHORIZATION"   // Authentication Failures -
    BadRequest      Type = "BADREQUEST"      // Validation errors / BadInput
    Conflict        Type = "CONFLICT"        // Already exists (eg, create account with existent email) - 409
    Internal        Type = "INTERNAL"        // Server (500) and fallback errors
    NotFound        Type = "NOTFOUND"        // For not finding resource
    PayloadTooLarge Type = "PAYLOADTOOLARGE" // for uploading tons of JSON, or an image over the limit - 413
)

// Error holds a custom error for the application
// which is helpful in returning a consistent
// error type/message from API endpoints
type Error struct {
    Type    Type   `json:"type"`
    Message string `json:"message"`
}

// Error satisfies standard error interface
// we can return errors from this package as
// a regular old go _error_
func (e *Error) Error() string {
    return e.Message
}

// Status is a mapping errors to status codes
// Of course, this is somewhat redundant since
// our errors already map http status codes
func (e *Error) Status() int {
    switch e.Type {
    case Authorization:
        return http.StatusUnauthorized
    case BadRequest:
        return http.StatusBadRequest
    case Conflict:
        return http.StatusConflict
    case Internal:
        return http.StatusInternalServerError
    case NotFound:
        return http.StatusNotFound
    case PayloadTooLarge:
        return http.StatusRequestEntityTooLarge
    default:
        return http.StatusInternalServerError
    }
}

// Status checks the runtime type
// of the error and returns an http
// status code if the error is model.Error
func Status(err error) int {
    var e *Error
    if errors.As(err, &e) {
        return e.Status()
    }
    return http.StatusInternalServerError
}

/*
* Error "Factories"
 */

// NewAuthorization to create a 401
func NewAuthorization(reason string) *Error {
    return &Error{
        Type:    Authorization,
        Message: reason,
    }
}

// NewBadRequest to create 400 errors (validation, for example)
func NewBadRequest(reason string) *Error {
    return &Error{
        Type:    BadRequest,
        Message: fmt.Sprintf("Bad request. Reason: %v", reason),
    }
}

// NewConflict to create an error for 409
func NewConflict(name string, value string) *Error {
    return &Error{
        Type:    Conflict,
        Message: fmt.Sprintf("resource: %v with value: %v already exists", name, value),
    }
}

// NewInternal for 500 errors and unknown errors
func NewInternal() *Error {
    return &Error{
        Type:    Internal,
        Message: fmt.Sprintf("Internal server error."),
    }
}

// NewNotFound to create an error for 404
func NewNotFound(name string, value string) *Error {
    return &Error{
        Type:    NotFound,
        Message: fmt.Sprintf("resource: %v with value: %v not found", name, value),
    }
}

// NewPayloadTooLarge to create an error for 413
func NewPayloadTooLarge(maxBodySize int64, contentLength int64) *Error {
    return &Error{
        Type:    PayloadTooLarge,
        Message: fmt.Sprintf("Max payload size of %v exceeded. Actual payload size: %v", maxBodySize, contentLength),
    }
}
Enter fullscreen mode Exit fullscreen mode

Next Time

We now have the basics of our model layer scaffolded out which will allow us to write our first handler, service, and repository implementations.

Thanks again for taking time to read! I hope you'll follow along with the series.

Top comments (0)