How are people managing CRUD reasonably

Are you trying to say that you want to route via convention? Because I don’t see a lot of repeated code there. In my experience, routing via convention is almost always a bad idea. But, you could create a layer of abstraction that you control to more or less accomplish this. Let’s create a struct called CRUDRouter (and for fun let’s use generics since they’re new and shiny) that will register routes for us and handle them. First, let’s create an interface that defines the operations we want each item to be able to perform:

type CRUDModel[T any] interface {
	Create() T
	GetList() []T
	GetSingleItem(ID int) T
	UpdateItem(ID int) T
	Delete(ID int) bool
}

Now that we have our interface, let’s create our CRUDRouter and put a type constraint on it to ensure any types passed in implement CRUDModel:

// CRUDrouter is a simple abstraction so we don't have to repeat our standard
// GET/POST/PUT/DELETE routes for every record type. Meant for simple 
// CRUD operations.
type CRUDrouter[T CRUDModel[T]] struct {
	// The base for our multiplexer
	muxBase string
}

So far so good, right? But we need to handle requests. Let’s create a handler:

// handle a CRUD request
func (rtr *CRUDrouter[T]) handle(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r.URL.EscapedPath())
	// We need ID for anything other than a GET to muxbase (AKA list request)
	if r.URL.EscapedPath() != rtr.muxBase && err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	// Instantiate zero-value for our model
	var model T
	switch r.Method {
	case http.MethodGet:
		// List items
		if id == 0 {
			items := model.GetList()
			json.NewEncoder(w).Encode(&items)
		} else {
			// Get single item
			item := model.GetSingleItem(id)
			json.NewEncoder(w).Encode(&item)
		}
	case http.MethodPost:
		// Create a new record.
		json.NewDecoder(r.Body).Decode(&model)
		model.Create()
	case http.MethodPut:
		// Update an existing record.
		json.NewDecoder(r.Body).Decode(&model)
		model.UpdateItem(id)
	case http.MethodDelete:
		// Remove the record.
		model.Delete(id)
	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}

This is a pretty naive implementation of course and I didn’t even bother using a router, so we are just parsing the URL’s path using the following function:

// TODO: add router so you don't have to do this. This example is simple and
// contrived and uses only stdlib.
func parseID(path string) (int, error) {
	splitStrs := strings.Split(path, "/")
	if len(splitStrs) == 0 {
		return 0, errors.New("Invalid ID param")
	}
	return strconv.Atoi(splitStrs[len(splitStrs)-1])
}

OK so now we have a generic, reusable struct that can handle GET, POST, GET/:ID, PUT/:ID and DELETE/:ID but we have to register our routes with a multiplexer of some sort. Let’s assume we are just using stdlib and *http.ServeMux:

// registerRoutes registers standard CRUD routes with our multiplexer
func (rtr CRUDrouter[T]) registerRoutes(mux *http.ServeMux) {
	mux.HandleFunc(rtr.muxBase, rtr.handle)
	mux.HandleFunc(fmt.Sprintf("%v/", rtr.muxBase), rtr.handle)
}

What about a convenience function that wraps this into a single line?

// NewCRUDRouter instantiates a new CRUD router for a given record
// type and registers the routes with your multiplexer.
func NewCRUDRouter[T CRUDModel[T]](muxBase string, mux *http.ServeMux) *CRUDrouter[T] {
	router := CRUDrouter[T]{
		muxBase: muxBase,
	}
	router.registerRoutes(mux)
	return &router
}

… and how do you use it? Let’s assume we have structs of type Workspace (as per your example) and User that implement CRUDModel. You can use this like so:


func main() {
	router := http.NewServeMux()
	// Handles the following:
	// GET    /workspaces
	// POST   /workspaces
	// GET    /workspaces/:id
	// PUT    /workspaces/:id
	// DELETE /workspaces/:id
	NewCRUDRouter[Workspace]("/workspaces", router)
	// Handles the following:
	// GET    /users
	// POST   /users
	// GET    /users/:id
	// PUT    /users/:id
	// DELETE /users/:id
	NewCRUDRouter[User]("/users", router)
	log.Fatal(http.ListenAndServe(":8080", router))
}

Here’s a playground link that won’t work on the playground, but un-comment the lines per instructions and you can run this locally (either copy/paste it local or hit ctrl+s):

In summary: I was able to implement routing via convention using nothing but the stdlib with very little effort. You could easily take this further. If you wanted, you could never define a single route and run your web server based solely on convention. I don’t think that is a great idea (and I don’t think I’m alone there given that this isn’t a major paradigm in any of the larger go routing frameworks I know of) but you could do it if you wanted to.

This also gives you compile time checking. Consider the following where BogusStruct doesn’t implement CRUDModel:

NewCRUDRouter[BogusStruct]("/workspaces", router)
// Output: 
// BogusStruct does not implement CRUDModel[BogusStruct] (missing Create method)

You get compile-time checking. When you add all this together and get used to it, trust me: you are going to end up with more predictable web APIs that have fewer errors because you’re catching them at compile time. And that becomes more important the larger the project, not less. Same with performance.

3 Likes