Cover Photo by Douglas Lopes on Unsplash
Intro
Lately, I had a chance to try out the API-First design approach. I had never written an OpenAPI document before, so I had no real knowledge of its benefits. It always seemed like too much prep work.
As developers, we often prefer writing code to writing documentation. We dive straight into coding, eager to see our project in action. However, I recently discovered a game-changing approach that has transformed my development process: API-First design. In this post, I'll share my experience implementing this method in a full-stack hobby project, highlighting how it streamlined my workflow and why it's worth considering for your next side project.
tl;dr: It will force you to think about your users and how they use your API before writing any code.
I’ve been working on a full-stack hobby project where my backend and frontend use different languages (Go and SvelteKit). I decided to give this approach a try and had my “aha” moment. I wish I had done it before.
Prioritizing Your Application's Foundation
The API is how we are going to expose our app functionality. An API-first design approach prioritizes the development of APIs before implementing other parts of a software system (or writing code). This method focuses on creating a well-designed, consistent, and user-friendly API that is the foundation for the entire application.
This methodology places the API at the center of the development process, treating it as a first-class citizen rather than an afterthought. Your API comes first, then the implementation.
With a written API specification, we can leverage code generation tools to create some boilerplate code. By defining objects in the specification, code-gen tools can generate the relevant structs, for both the frontend and backend (yes, even when the language used is different). This is a big time saver and it helps us to be consistent.
What is Open API?
“The OpenAPI Specification (OAS) defines a standard, language-agnostic interface to RESTful APIs which allows both humans and computers to discover and understand the capabilities of the service without access to source code, documentation, or through network traffic inspection.”
Simply put, it’s a contract that describes your API types and endpoints. You list all your API endpoints, their HTTP methods, what they possibly return, and some description of what they do.
Now, what if I told you, you can use this document to improve and accelerate your dev experience?
Once I had this document, that describes the contract between my API server and its clients, these are the things I could do:
- Generate my backend types (Go)
- Generate my frontend types (Typescript)
- Generate a client code for my server (also Typescript)
- Generate a testing client with Insomnia or Postman
This is a lot of boilerplate code I could save myself from writing. It ensures the frontend and backend types are synchronized since both are generated.
Grab a 🍺, and let’s walk through an example.
The Project Structure
We will be using Go for the backend and some JS framework for the frontend, and a simple structure would look like:
app/
├─ api/ -- the place for the Swagger OpenAPI document
├─ client/ -- the client-side code
├─ cmd/
│ ├─ app.go -- thin main func that runs our API server
├─ internal/
│ ├─ api/
│ │ ├─ main.go -- for code-gen
│ ├─ users/
│ │ ├─ handlers.go -- implements the API contract
Generate The APIs
Let's create our API specification. It includes two endpoints and two structs: User and Error.
Place this file under your /api
directory
openapi: 3.0.3
info:
title: Devopsian OpenAPI Example
version: 0.1.0
contact:
name: dev
url: https://devopsian.net
servers:
- url: "http://localhost/v1"
components:
schemas:
Error:
type: object
required:
- code
- message
properties:
code:
type: integer
format: int32
message:
type: string
User:
type: object
required:
- id
- name
- email
properties:
id:
type: string
name:
type: string
email:
type: string
format: email
paths:
/user:
get:
description: Get the current logged-in user
responses:
200:
description: user response
content:
application/json:
schema:
$ref: "#/components/schemas/User"
default:
description: error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/signup:
post:
description: Creates a new user
responses:
200:
description: Creates a user
content:
application/json:
schema:
$ref: "#/components/schemas/User"
default:
description: error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
email:
type: string
format: email
Generate Server-Side Code
To generate the server-side code, we need some library. I found oapi-codegen for that. It supports many popular HTTP libraries (echo, gin, etc.) At the time of writing, I used oapi-codegen@v2.3.0
Add the following files to your /internal/api
directory
// /internal/api/main.go
//go:generate oapi-codegen --config cfg.yaml ../api/openapi3.yaml
package api
// make sure to install:
// go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.3.0
# /internal/api/cfg.yaml
package: api
output: server.gen.go
generate:
models: true
echo-server: true
I’m using the echo web framework, you can browse the library documentation to use other frameworks. Now run go generate ./...
and it will generate the interfaces (handlers) your web server has to implement to fulfill this contract, including the types.
Interface Implementation
Now it’s time to write the implementation. We create a users
package where all the user's API handlers, business logic, storage, etc. are defined. We will keep it simple and implement the handlers with static content.
type UsersHandler struct {
DB *sql.DB
}
func (u *UsersHandler) GetUser(ctx echo.Context) error {
// load the user from the database and return it to the caller
return ctx.JSON(http.StatusOK, api.User{
Email: types.Email("demo@devopsian.net"),
Name: "DemoUser",
Id: "1",
})
}
func (u *UsersHandler) PostSignup(ctx echo.Context) error {
var body api.PostSignupJSONBody
if err := ctx.Bind(&body); err != nil {
return ctx.JSON(http.StatusBadRequest, api.Error{Code: http.StatusBadRequest, Message: "invalid request"})
}
// save user in database
return ctx.NoContent(http.StatusOK)
}
func New(db *sql.DB) *UsersHandler {
return &UsersHandler{DB: db}
}
Next, we need to create our web server entry point, we define that at cmd/server.go
type Server struct {
users.UsersHandler
}
func main() {
e := echo.New()
s := Server{UsersHandler: *users.New(nil)}
api.RegisterHandlers(e, &s)
e.Logger.Fatal(e.Start(":8080"))
}
Note I explicitly pass in nil
as DB implementation for this example, because we don’t use it.
That’s it. If the code compiles, our server implements the API contract. All the API endpoints are handled by the spec. If I had missed something, it would have broken at compile time.
How nice is that?
Generate a Typescript Client
It’s time to generate a client for our API. We use the same openapi schema file to generate a JS client. I won’t include a full frontend project in this example, but rather show how you can generate a client to an existing one.
In the client/
directory, install the code-generation tool for JS:
npm install openapi-typescript-codegen --save-dev
. (This post was tested with v0.29.0)
Create a client/api/
directory, and let’s run the tool:
npx openapi-typescript-codegen --input ../api/openapi3.yaml --output api/ --name ApiClient
.
This will generate a bunch of typescript files. To use our client we need to create an instance of it.
// api.ts
import { ApiClient } from './api/ApiClient'
const client = new ApiClient().default
// client has all the methods of our API:
// - getUser()
// - postSignup(requestBody: {name?: string, email?: string})
That's it.
Summary
API-First design isn't just another development buzzword—it's a powerful approach that can significantly enhance your side projects.
By prioritizing your API design before implementation, you gain clarity, consistency, and efficiency.
The OpenAPI specification is a contract between your frontend and backend, enabling automatic code generation for types, clients, and even testing tools.
This approach not only saves time but also ensures better synchronization between different parts of your application.
While it may seem like extra work upfront, the long-term benefits—including improved development speed, reduced errors, and better API documentation—make it a valuable investment for any side project.
If you haven't tried it yet, now might be the perfect time to give it a shot and experience these benefits firsthand.