To conclude my series on full-stack development, this part will look at the implementation, design choices, what worked well and what didn’t.
This builds on the to-do list project proposed in part 1 and the tech stack described in part 2.
API design#
The API was designed with REST principles, focusing on user and to-do list management. The endpoints are as follows:
POST /users/register
- Register a new userPOST /users/login
- User loginPOST /todolists
- Create a new todo listGET /todolists/{list_id}
- Get a specific todo listPOST /todos
- Create a new todo itemPUT /todos/{todo_id}
- Update a todo item
Notice how all resource names are plural.
The /users
deviate slightly from the others as I felt they were different from the others: they are non-authenticated and not designed for other interactions beyond POST
.
However, if I would design these again, I would use:
POST /users
- Create a new userPOST /tokens
- Create a login token
This makes it more natural to support editing users or revoking/expiring tokens later on.
Debug endpoint#
A handy trick I found was adding a /debug
endpoint, which allows to easily inspect the database:
curl http://localhost:8080/debug | jq
This returns the database:
func (u *Debug) Debug(w http.ResponseWriter, _ *http.Request) {
net.Success(w, u.database)
}
Which gets serialized to JSON, because it’s just an in-memory struct:
{
"Users": {
"usr_Fq8uKUngJmHfUjKvf66TdJ": {
"Id": "usr_Fq8uKUngJmHfUjKvf66TdJ",
"Name": "Jeroen"
}
},
"AccessTokens": {
"tkn_5Ao9yEgb8Cp2ba7Rab7ntn": {
"UserId": "usr_Fq8uKUngJmHfUjKvf66TdJ",
"Token": "tkn_5Ao9yEgb8Cp2ba7Rab7ntn"
}
},
"TodoLists": {
"lst_PhKGbn4dDPARkeSVqDVm3E": {
"Id": "lst_PhKGbn4dDPARkeSVqDVm3E"
}
},
"TodoItems": {
"tdo_gkGPiPuvTjx2Dr3fpQqFaQ": {
"Id": "tdo_gkGPiPuvTjx2Dr3fpQqFaQ",
"ListId": "lst_PhKGbn4dDPARkeSVqDVm3E",
"UserId": "usr_Fq8uKUngJmHfUjKvf66TdJ",
"UpdatedAt": "2024-08-01T22:34:14.727375+02:00",
"Description": "Write blog post about this project",
"Status": "ongoing"
}
},
"TodoItemOrder": [
"tdo_gkGPiPuvTjx2Dr3fpQqFaQ"
]
}
Security#
Endpoints are secured using an access token, passed in the authorization header without the bearer
prefix (which I should have done).
curl -X POST "http://localhost:8080/todolists" \
-H "Authorization: $TOKEN" \
-d '{}'
An authentication middleware checks the authentication for all requests:
var nonAuthenticatedEndpoints = []string{"/users/register", "/users/login"}
func AuthenticationMiddleware(next http.Handler, database db.Database) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if slices.Contains(nonAuthenticatedEndpoints, r.URL.Path) {
next.ServeHTTP(w, r)
return
}
if _, err := database.GetAccessToken(r.Header.Get("Authorization")); err != nil {
HaltUnauthorized(w, err.Error())
return
}
next.ServeHTTP(w, r)
})
}
Fun fact: I initially wanted to pass the token as part of the POST body, which keeps requests “simple” and prevents CORS from being enforced. But then I realized GET requests don’t have a body so… 😅
To handle CORS, I implemented a CORS middleware. It took me a while to get this working since you need to return early to handle browser preflight requests.
func CorsMiddleware(next http.Handler, origin string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodOptions {
// Early return for CORS preflight
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
Backend#
Code structure#
The backed code consists of 4 packages:
- db -> database layer
- net -> http middleware and utilities
- routes -> all routes
- util -> utitlites (mainly for testability)
The main.go file is the entry point that defines all routes and starts the server. To better learn Go, I chose the built-in net/http.ServeMux over a framework like Gin.
func main() {
mux := http.NewServeMux()
database := db.CreateDatabase()
users := routes.CreateUsers(database)
mux.HandleFunc("POST /users/register", users.Register)
handler := net.AuthenticationMiddleware(mux, database)
err := http.ListenAndServe("localhost:8080", handler)
if err != nil {
fmt.Println(err.Error())
}
}
Routes#
Routes are each implemented in their file with one handler function per endpoint. Each handler parses the body, does some additional validations, updates the database, and returns a result.
func (u *Users) Register(w http.ResponseWriter, r *http.Request) {
body, err := net.ParseBody[registerRequest](r)
if err != nil {
net.HaltBadRequest(w, err.Error())
return
}
if !regexp.MustCompile(userNameRegex).MatchString(body.Name) {
net.HaltBadRequest(w, "invalid user name")
return
}
user := u.database.CreateUser(body.Name)
response := registerResponse{
UserId: user.Id,
}
net.Success(w, response)
}
In-memory database#
The in-memory database is a struct consisting of several maps to store all data:
type InMemoryDatabase struct {
Users map[string]User
AccessTokens map[string]AccessToken
TodoLists map[string]TodoList
TodoItems map[string]TodoItem
TodoItemOrder []string
}
Whenever a resource is created, the database generates a UUID and uses that as the key to store resources in a map.
func (d *InMemoryDatabase) CreateUser(name string) *User {
user := User{
Id: d.generateUuid("usr"),
Name: name,
}
d.Users[user.Id] = user
return &user
}
This makes storing/getting resources incredibly easy, but it did end up biting me quite hard. Since maps aren’t ordered collections, to-do items would be returned in a random order, which was very hard to debug.
To solve this, I had to add an additional TodoItemOrder
slice to keep track of the order of TodoItem
in each TodoList
. Which is not great, and a real database would have solved this for me out of the box with auto-incrementing IDs.
The database.go defines the CRUD operations on all resources.
Identifiers#
To uniquely identify all resources I used base58
encoded identifiers. This is a cool scheme for identifiers as it results in relatively short identifiers that avoid ambiguous characters.
I used prefixes to identify the resource type: usr
, tkn
, lst
, and tdo
.
"usr_Fq8uKUngJmHfUjKvf66TdJ"
"tkn_5Ao9yEgb8Cp2ba7Rab7ntn"
"lst_PhKGbn4dDPARkeSVqDVm3E"
"tdo_gkGPiPuvTjx2Dr3fpQqFaQ"
Since these are URL safe, a future web app could just expose these in the different page URL paths.
Business logic#
To make things more interesting, we wanted to disallow certain TODO state transitions:
- ❌ todo -> done
- ❌ done -> todo
Typically you want to enforce such data integrity as close to your database layer as possible. For instance, using a SQL constraint or a trigger.
However, given that we don’t have a real database, this logic is built into the application code:
func (t *Todos) Update(w http.ResponseWriter, r *http.Request) {
...
err = item.ChangeStatus(body.Status)
if err != nil {
net.HaltBadRequest(w, err.Error())
return
}
}
And the data model has the business logic implemented:
func (t *TodoItem) ChangeStatus(newStatus string) error {
if t.Status == "todo" || t.Status == "done" {
if newStatus == "ongoing" {
t.Status = newStatus
return nil
}
return errors.New(fmt.Sprintf("invalid status transition from %s to %s", t.Status, newStatus))
} else if t.Status == "ongoing" {
if newStatus == "done" || newStatus == "todo" {
t.Status = newStatus
return nil
}
return errors.New(fmt.Sprintf("invalid status transition from %s to %s", t.Status, newStatus))
}
return errors.New(fmt.Sprintf("invalid status transition from %s to %s", t.Status, newStatus))
}
Like most other classes in the backed, this business logic also has unit tests:
func TestTodoItem_ChangeStatus(t *testing.T) {
tests := []struct {
oldStatus string
newStatus string
error bool
}{
// Allowed transitions
{
oldStatus: "todo",
newStatus: "ongoing",
error: false,
},
...
// Disallowed transitions
{
oldStatus: "todo",
newStatus: "done",
error: true,
},
...
}
for _, tt := range tests {
t.Run(fmt.Sprintf("from %s to %s", tt.oldStatus, tt.newStatus), func(t *testing.T) {
item := TodoItem{
Id: "fake_id",
UpdatedAt: util.FakeTime(2021, 1, 1),
Description: "fake description",
Status: tt.oldStatus,
UserId: "fake_user",
}
err := item.ChangeStatus(tt.newStatus)
if err != nil || tt.error {
expected := fmt.Sprintf("invalid status transition from %s to %s", tt.oldStatus, tt.newStatus)
assert.Equal(t, tt.error, true)
assert.Equal(t, expected, err.Error())
} else {
assert.Equal(t, tt.newStatus, item.Status)
}
})
}
}
Frontend#
Code structure#
The front end consists of three main folders:
- components -> the three screens + error banner
- net -> models and logic for network requests
- utils
App.svelte
contains the main logic to decide what’s being shown:
{#if todoList != null}
<Todo {accessToken} todoListId={todoList} />
{:else if accessToken != null}
<ChooseList {accessToken} {onListSelected} />
{:else}
<LogIn {onLogIn} />
{/if}
Whenever the user logs in, the Login
component calls onLogIn
on the main App such that the token can be stored in local storage:
<script>
let accessToken: string | null = localStorage.getItem('access_token')
const onLogIn = (token: string) => {
console.log('Received token:', token)
localStorage.setItem('access_token', token)
accessToken = token
}
</script>
This keeps users logged in across Electron app restarts.
All validations are done on the backend, but the frontend also has some minor validations to speed up error handling.
End-to-End Tests#
Here’s a snippet of the end-to-end test to get an idea of how it looks like:
test('end 2 end test', async () => {
const electronApp = await electron.launch({ args: ['.'] })
const window = await electronApp.firstWindow()
// Login
await window.getByText('Create Account').isVisible()
await window.getByRole('textbox').fill('jeroen')
await window.screenshot({ path: 'screenshots/intro.png' })
await electronApp.close()
}
I did run into a few challenges…
Make end-to-end tests easy to run#
To start the server when the tests run, I created two new scripts: one to start the server and one to run the end-to-end tests:
#!/bin/bash
# Start the backend server
(cd ../backend && ./start_server.sh)&
BACKEND_PID=$!
# Build and run tests
npm run build
npx playwright test --reporter=html
# Kill the backend server
kill $BACKEND_PID &> /dev/null
kill $(pgrep -f go-build) > /dev/null
This script is also aliased in the package.json file:
{
"scripts": {
"test:e2e": "./run_e2e_tests.sh",
"test:unit": "vitest"
}
}
Now tests can be run simply using npm run test:e2e
Reset state before tests#
A big drawback of remembering sessions across app starts is that the user could still be logged in when the app starts. Solving this after each test run is not sufficient as local, manual testing could also cause the user to be logged in.
So I needed to find a way to clear the localstorage in an Electron app before starting a test run. While this would be super easy in a browser, I couldn’t find a good way to do that on Electron. And to this day I’m still not sure if that’s because I’m missing something or because Playwrights support for Electron is still in beta.
So I ended up resorting to a hacky, mac only, solution that force clears the app cache:
# Clear the app cache of the Electron app
rm -r ~/Library/Application\ Support/frontend-electron
Result#
Wrap up#
Overall I think the project turned out quite well and I’m happy with most of the choices I made. You can find all source code on Github and try it out for yourself!
On the backend, Go turned out to be really fast and fairly easy to learn. Though the lack of inheritance is weird and its verbosity makes it not a pleasure to look at. I can understand Go’s appeal now though.
Svelte was a real treat! It’s so much simpler and more expressive than React is and I found it an utter joy to use. Vite indeed turned out to be fast and Playwright incredibly cool, just a shame that Electron isn’t well supported.
From a time perspective, I spent around 40 hours coding and 12 hours on this blog series, and I have two shippable artifacts to show! 🙌 So I’m pretty happy with how this project turned out and especially what I learned.
Special thanks to Daniel Gollahon for proofreading an early draft of this post and offering many thoughtful suggestions.
If this was helpful to you, consider buying me a coffee.