Skip to content

Software & Deployment Architecture

Michael Crilly edited this page Mar 10, 2018 · 8 revisions

The overall architecture is:

  • Individual models in their own Go packages, abstracted enough to be Lambda or binary
  • Individual models and logic as a Lambda, supported by a single endpoint in API Gateway
  • A single DynamoDB Table backing each model
  • Serverless for deploying all functions into AWS Lambda

Software

For the software's architecture, we're going to define a few key patterns to follow. This isn't to put in place hard and fast rules, but instead, simply ensure we have a path to follow.

Code base Structure and (Sub-) Packages

When writing a Lambda for an API Gateway endpoint, which essentially represents a model in the application, we will create a new sub-package under the SWL root path. This means each endpoint is an individual package and can be:

  1. Tested in isolation
  2. Can be a Lambda function deployed via Serverless
  3. Can be a standalone application
  4. Can be used in a bigger, stand alone project as a module - i.e. redeployed outside of Lambda as a monolith

I believe this structure will give us flexibility and make deployments via Serverless very easy.

Create Lambda and Standalone Binary Packages

What we need here to ensure each sub-package, as defined above, can actually be deployed as a Lambda or a binary. This is somewhat a repeat of the above, but it's important to be clear because it requires smart choices during the code base's inception.

In short, we simply need to ensure that we're writing a RESTful API (as close to RESTful as we want or need to get to, anyway) that can be interacted with via a Lambda Handler OR via a Go net/http server. This means keeping each endpoint's core business logic fully separate from the code handling the incoming connection.

Let's consider an example. If we have an endpoint that takes in a name and stores it in a database, we want to make sure the logic for storing the name is kept in a separate function to the logic that a) handles the Lambda invocation and b) the logic that handles a direct HTTP(S) connection from the public Internet, or even c) a connection from some other protocol, like something we make our selves using TCP, UDP, or whatever. Essentially, whatever handles the inbound connection needs to very quickly pass the work onto the business logic and then only return the result.

Go Libraries

Selecting and defining the libraries we use in our code isn't setting anything in stone, but it's just making sure we know what direction we want to go in. Let's cover what I believe are the correct libraries to be using, and why.

httprouter

This library is one of the best net/http replacements available. It's very simple and easy to use, and it's extremely fast. This is a perfect choice for handling our connections coming over a direct HTTP(S) connection instead of via a Lambda handler.

Viper

When it comes to configuration from files, the environment, remote APIs, and other sources, nothing beats Viper. It's an incredibly powerful library that makes handling configuration for your application a complete breeze. It really has no competition at all.

dynamock

As we're going to be using DynamoDB, we'll need to be able to mock it for testing purposes. This creating speeds up the ability to test our software changes without having to incur charges or suffer latency issues as we wait for requests to be completed by the real AWS DynamoDB.

moq

There may be times when we abstract away functionality and require an interface. When this happens, it's likely we will need and want to create a mock implementation of the interface: step in moq. It's an excellent library that will read an interface's definition and create a concrete implementation for use in tests.

gotests

With gotests, we can very quickly generate table driven unit tests for all of our functions and even receivers on structs. Combined with moq, it leaves virtually no excuse for not having tests in place as it outputs all the boiler plate unit testing code, only leaving you the task of refining the tests and adding in test cases.

aws-sdk-go

This one should be somewhat obvious, really. We'll likely need to use this library for various interactions with AWS services such as DynamoDB, S3, CloudWatch Logs, etc. It will become quickly apparent whether or not we need this library.

Dependency Management

For managing our libraries in our Go based code, we shall use the dep dependency management utility. It's extremely good, and is actually becoming, or might have become, the official dependency management tool for Go.

Common Lambda Handlers

We need to write a generic, common handler for each package so that the inbound request is being handled in a highly consistent manner and then quickly being handed off to some internal function. This sounds somewhat obvious or maybe it sounds restrictive, but what we're trying to do here is simply keep things simple and consistent among all the handlers so there are no surprises when coming back later on to maintain or update the code.

HTTP(S) Endpoints

Because we're handling inbound traffic from the public, we need to define an idea around what the URLs are going to look like. It doesn't have to be anything concrete now, just a guideline to work from.

When providing URLs for the client to work with, we need to make them somewhat obvious and simple to understand. Let's begin by defining the interactions people will have with our API:

  • Create, update, and delete an account
  • Create, update, and delete wish lists
  • Create, update, and delete items for wish lists
  • Add and remove friends from their account
  • Add and remove friends from wish lists on their account (invitations)
  • Login and logout from their account

Based on these simple CRUD (Create, Read, Update, Delete) operations, we can create endpoints along these lines:

Account

  • /account/
  • /account/create
  • /account/update
  • /account/delete
  • /account/find

Wish Lists

  • /wishlist/
  • /wishlist/create
  • /wishlist/update
  • /wishlist/delete
  • /wishlist/invite
  • /wishlist/accessible

Wish List Items

  • /item/
  • /item/create
  • /item/update
  • /item/delete

Friends

  • /friend/
  • /friend/add
  • /friend/remove

Invitations

  • /invitation/
  • /invitation/add
  • /invitation/remove

Authentication

  • /login
  • /logout

Models & DynamoDB Tables

We're going to need models for each of the endpoints we've listed above. These models represent state, and that state is stored in DynamoDB Tables. We will add, update, and remove state from the Tables as and when the user interacts with the service.

The design we're taking here is to define each model as a struct{} which complies with a particular interface to ensure certain methods are available on each model, such as being able to Add(), Remove(), and Update() model entries from its respective Table.

Interfaces

We will need an interface for our database access. I have two goals in mind with regards to database access:

  1. I want to be able to push to DynamoDB today
  2. I want to be able to push to any other database tomorrow

It's possible we'll expand the project to include a time series database, or some kind of interaction with a remote API. For us to be able to do this, we need to abstract the API each model uses for databases access. We can do this using an interface.

There's also the topic of mocking for testing purposes. We don't want to run a local DynamoDB instance (such a thing exists) just to run tests. That's too heavy. Instead we can mock the database access interface for each model and uses mock data, too.

Models

This is going to be a bigger section than above, possibly the biggest in the documentation, but it's an important one and will evolve a lot over time.

Keep in mind that at the time of writing, these models have been kept simple for v1. It's highly likely these models will change a lot over the course of development as feedback comes in from the public and other developers.

Note: Login and Profile seem like the same thing, but it's considered best practice to keep them separated. This enables the user to change their password, username, email address, etc, all independently of their profile. It means they can also have two or more logins per profile, such as via a username/password combination or an API key. You can do other funky things with this separation too, like handing over an account to someone else, like a transfer, by simply transferring your Profile under their Login so that the user has two profiles to play with. Not all of these features will be implemented, they're just examples.

Each model will have a database interface associated with it, also. See above.

Login

type Login struct {
	ID           string
	OwnerID      string
	Username     string
	PasswordHash string
	Enabled      bool
	Verified     bool
}

type DBI interface {
	GetByID(string) (Login, error)
	GetByOwnerID(string) (Login, error)
	GetByUsername(string) (Login, error)

	Add(Login) error
	Delete(string) error
	Update(Login) error
}

Profile

type Profile struct {
	ID        string
	LoginIDs  []string
	RealName  string
	Email     string
	DOB       time.Time
	Friends   []Friend
	WishLists []WishList
}

type DBI interface {
	GetByID(string) (Profile, error)
	GetByRealName(string) (Profile, error)
	GetByEmail(string) (Profile, error)

	Add(Profile) error
	Delete(string) error
	Update(Profile) error
}

WishList

type WishList struct {
	ID          string
	OwnerID     string
	Title       string
	Description string
	Active      bool
	Private     bool
	Items       []WishListItem
	Friends     []Friend
}

type DBI interface {
	GetByID(string) (WishList, error)
	GetByOwnerID(string) (WishList, error)

	Add(WishList) error
	Delete(WishList) error
	Update(WishList) error

	GetItems(WishList) ([]WishListItem, error)
	GetFriends(WishList) ([]Friend, error)

	InviteFriends(string, []Friend) error
	RemoveFriends(string, []Friend) error
}

Item

type WishListItem struct {
	ID          string
	OwnerID     string
	ListID      string
	Title       string
	Description string
	Cost        float32
	Rating      uint
	Bought      bool
	BeingBought bool
	Active      bool
}

type DBI interface {
	GetByID(string) (WishListItem, error)
	GetByOwnerID(string) (WishListItem, error)
	GetByListID(string) (WishListItem, error)

	Add(WishListItem) error
	Delete(string) error
	Update(WishListItem) error
}

Friend

type FriendStatus uint

const (
	noresponse = FriendStatus(0)
	accepted   = FriendStatus(1)
	rejected   = FriendStatus(2)
)

type Friend struct {
	ID        string
	OwnerID   string
	ProfileID string
	Status    FriendStatus
}

type DBI interface {
	GetByID(string) (Friend, error)
	GetByOwnerID(string) (Friend, error)
	GetByProfileID(string) (Friend, error)

	Add(Friend) error
	Delete(string) error
	Update(Friend) error
}

Deployment

The concept of deploying code is a simple one: get your product out there and live for the world to see and use. We're going to outline some basics around what we're deploying and how.

Lambda and API Gateway

Our application is going to use AWS Lambda and API Gateway for handling the compute resources and API, respectively. Lambda is going to house our functions and each function will handle one HTTP(S) endpoint (which we defined above.) We will use API Gateway to route traffic from the public Internet to individual functions based on the URL requested. This is pretty simple to understand, but here's an ASCII diagram demonstrating the traffic flow:

Client -> Internet -> API Gateway -> Lambda (-> Maybe DynamoDB, maybe some cache)

To get this in place, we're going to use Serverless.

Serverless Framework

The Serverless Framework is an excellent piece of kit. It enables us to define what our application looks like based on routes and functions, among other things, and it produces the CloudFormation required to get it setup and running. It doesn't just dump CloudFormation in our lap, however. It actually applies the CloudFormation for us, so with one command we can have a whole application up and running.

In this document we won't discuss or look at specifics. Instead we will do that in another document in the wiki.

Single File Deployment

What we're doing here is defining a single serverless.yml file for deploying the entire stack. This will be helped along nicely with our code structure as discussed earlier. Each function will be compiled and placed into a sub-package, meaning all Serverless has to do pair up an API Gateway route/endpoint with a Lambda function, pass through the arguments, and we're done.

With a single command, sls deploy, we'll have our application deployed.

Environments

And finally, we will make sure that the serverless.yml file supports us passing in an environment name, allowing each deployment to be unique and isolated. This has proven useful to me in the past when wanting to stand up multiple instances of an application for testing or for developers to use.