/archex5

Architectural Example 5 - Event Sourcing in Golang - Bluecore style

Primary LanguageGoApache License 2.0Apache-2.0

Arch Example

Running the example

At the current time (step 6 complete), you can start the API server which will use a memory based event store with

$ go run main.go server

Help is available with

$ go run main.go --help

Samples for the API

At the current time (step 6 complete), the API consists of:

Create a product

POST localhost:8080/api/command

{
    "commandType": "create-product",
    "source": "test",
    "product": {
        "ns": "nike",
        "sequeceNum": 0,
        "SKU": "102",
        "title": "Jordan Delta Breathe",
        "description": "Inspired by high-tech etc",
        "images": ["https://via.placeholder.com/600/c984bf",
		"https://via.placeholder.com/400/abcdef"],
        "primaryImgIdx": 0,
        "is_active": true,
        "url": "",
        "price": 129.99
    }
}

Headcheck

POST localhost:8080/api/command

{
    "commandType": "product-headcheck",
    "ns": "nike",
    "sku": "103",
    "reason": "oh, no reason."
}

Update Price

POST localhost:8080/api/command

{
    "commandType": "update-product-price",
    "ns": "nike",
    "sku": "102",
    "price": 129.99
}

Get Stream IDs within Namespace

GET localhost:8080/api/{namespace}/products

Get Product Aggregate

GET localhost:8080/api/{namespace}/products/{sku}

Step 1 - Scaffold

This ArchEX5 project has branches that show the result of doing blocks of steps. Except for the first step b/c I blew away the branch... but it's simple enough, the result of minimally scaffolding out the project

  1. From GOROOT which for me is ~/go, create a folder under github.com/[user]/[project]. This is the project root folder
  2. Initialize a git repo here
  3. Touch go.mod, add one line go 1.15
  4. Create hello world main.go in project root
  5. Verify it runs. From project root:
$ go run main.go

Branch step-2-cobra Configuration and CLI

At Bluecore we use cobra to create CLIs. In this step we add a reference to the package and scaffold out our first commands.

  1. Run the install steps from the cobra docs. Cobra implements a CLI itself that allows you to quickly add configuration and CLI abilities to your project.
  2. Use the cobra CLI to initialize cobra in this project
$ cobra init --pkg-name github.com/[user]/[project] .
  1. Clean up the comments and unneeded stuff cobra created, and edit the cmd/root.go for our use. Currently the root execution (with no command line parameters) will not do anything other than print usage.
    • Cobra init has also set up viper, which is makes it easy to get config information from the command line, config files, environment variables, and more. See the repo for details.
    • Lastly Cobra init installs go-homedir which is a cross platform lib to get the running process home directory
  2. Add a hello command using cobra
    • Adds hello.go in your cmd folder
    • Wires the hello command to the root command which makes it available
    • You'll now see help for the hello when you run the program and without commands or with --help
$ cobra add hello

Branch step-3-mux Gorilla Mux

At Bluecore we use Gorilla MUX for APIs in Go.

  1. Install in our project
$ go get -u github.com/gorilla/mux
  1. Add a command to start an API server
$ cobra add server
  1. Modify the ServerCmd descriptions
  2. Set host and port as module level string variables to support the flags.
  3. Modify the init() function to configure flags for host and port, with logical defaults.

At this point we could start building the server out right here in the server command. It would be cleaner however to separate the actual server from the command that starts it.

Implementing the API Server

  1. At the project root, create a folder for the server module, call it API, and create an API.go within.
  2. In API.go create a handler for a home route. This will handle requests that go to the root of the API. For now we'll return a simple result to prove that things are working.
func homeHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("<h1>Gorilla!</h1>"))
}
  1. The Run function (which we could have named anything) is the entry point for the server. It expects a host and port, and will instantiate a router, add a handler for the home route, and finally start the server with the http.ListenAndServer function.
func Run(host string, port string) {
	router := mux.NewRouter()
	router.HandleFunc("/", homeHandler)
	addr := fmt.Sprintf("%s:%s", host, port)
	fmt.Printf("Server running. Listening on %s\n", addr)
	log.Fatal(http.ListenAndServe(addr, router))
}

That's it for now. Next step will stub out some routes for the sample application.

Branch step-4-models Models, Commands, Events

In this step we implement models, commands, events, and stub out the functions in the command processor.Since one of the goals is to build and demonstrate an event sourced system, it would be good to pick something where we can demonstrate handling contention, different read models, replay, and time travel.

I choose products to model, a simplified example product model, but it is interesting enough to have several commands and events to implement, and potentially we can show different reducers and projectors.

Models

This example has a single model (at this time), the ProductModel. The attributes are as you'd expect, with the exception of SequenceNum which is the last event in the stream that makes up this state of the ProductModel.

In an event sourced system there are any number of possible ways to combine the events in a stream into a model, but you typically see a "canonical" model used by the command processors. We'll see more of how event streams are turned into models when we get to the reducer step.

Commands

Commands aren't a necessary part of an event sourced solution; rather you see them typically in CQRS or Command Query Responsibility Separation pattern. The way to think of this is a command represents a request or attempt to take some action, often that action is to change the state of some durable entity in the system (in our case the main entities are Products).

A Key characteristic of a command is that it may fail for any number of legitimate reasons. For example, the action that is being requested might not be allowed under the constraints (business rules). Our product system may have a rule that disallows a product to be created in a namespace if that SKU already exists.

In our system the commands are found in ./commands/productCommands.go

Also note that these commands are not the same as the cobra.Command struct type used by the Cobra CLI and configuration package which is unfortunately a naming collision and is completely unrelated to CQRS Commands.

Command Processor

Commands are passed to command processors, in our case ./processor/productCmdProc.go. The command processor typically has one function that takes any valid command instance, determines the type, and dispatches the call to a function made specifically to handle that type of command:

func ProcessProductCommand(cmd interface{}) error {
	switch c := cmd.(type) {
	case *commands.CreateProductCmd:
		return ProcessCreateProduct(c)
	case *commands.HeadCheckCmd:
		return PerformHeadCheck(c)
...

Events

Finally we have the events, located in ./events/ProductEvents.go. Do not confuse these events with the "raw events" that are generated by user interaction with a web page (and other raw events) received by the API at Bluecore. In this context, events are very specifically "event sourcing" events.

Each event is a record of something that has happened and has been recorded in our system. That's why events are named in the past tense, for example:

type ProductCreated struct {
	Event
	Source  string              `json:"source"`
	Product models.ProductModel `json:"product"`
}

the ProductCreated event records the fact that the product was created. The command processor has the logic and opportunity to access resources such as databases or other APIs to determine if a command is valid when it is received, and what event or events should occur as a result of processing the command. Once the events are created and recorded, it is a permanent part of the history of the system. Events are immutable, once they are created and successfully written to the event store, they cannot be deleted during the normal course of events.

As a practical matter most event sourced systems allow for compaction and/or removal of events as part of a retention scheme, but that's out of scope of this exercise.

Branch step-6-cmdprocs Command Processors & Memory Event Store

Next we'll build the command processors. When a command hits a command processor, the job is to pull in whatever information is needed from the world to validate the command. Is it valid? If the command is valid and can be executed, the command processor performs whatever action is dictated by the command. In the most common case, the action to be executed is to mutate the durable state of the system (ie, to apply some CRUD operation). But that's definitely not the only possibility. Commands can trigger a credit card to be debited, an API call to be made to some external system, a stepper motor to articulate, a deploy to be started, etc.

In our case, commands are performing CRUD operations on the entities in our domain - products.