At incident.io, speed is essential. Our product is growing faster than ever; in scope, range of features and the number of people contributing to it. In the early days, when you’re a small startup with just a few hundred endpoints, a basic API setup gets you by. But as things scale, you need to make creating endpoints easy, fast, and reliable.
Fortunately, we’ve had our current API setup in place for a while now, and it’s a process that removes a lot of the excess boilerplate and lets us focus on creating a smart and efficient API.
My first project during the internship was creating the public alerts API. I was exposed to this setup on day two, and although I had not worked with Go before, the entire process for creating new endpoints felt incredibly streamlined and high-level — almost magical.
In this post, I’ll walk through exactly what this setup looks like, how we leverage Goa and then build on top of it to make it work specifically for our tech stack.
When I say we have had this setup for a while, I mean in order to research for this blog and see what the setup looked like before, I checked out to an old branch from when there were only six people at the company.
Back then, our API looked like a lot of others in the Go ecosystem: handlers were written manually, structs were defined ad hoc in each service, and OpenAPI (Swagger) docs were updated by hand (if and when someone remembered). There were no shared types between backend and frontend, and certainly no generated clients. It was the kind of setup that gets you moving quickly at first, but doesn’t scale well. This is something common in Go codebases. Frameworks like chi
, gorilla/mux
, or even the standard net/http
package are often used directly.
This then becomes a problem when you scale. You end up defining your request and response shapes multiple times across different layers, so changes become fragile and error-prone and without a shared source of truth between frontend and backend, every change to a request shape or response type become risky.
Throughout this blog we will use our showIncidents
endpoint as examples. This is a snippet of how this endpoint would have looked with our old setup:
// Back then: Manual handler with lots of boilerplate
func (h *Service) APIIncidentsList(gctx *gin.Context) {
// Manual parameter parsing and validation
ctx := gctx.Request.Context()
session := sessions.Default(gctx)
organisationID := session.Get("organisation_id").(string)
externalIncidentID, err := strconv.ParseInt(gctx.Param("incident_id"), 10, 64)
if err != nil {
log.Info(ctx, "bad external incident ID", map[string]interface{}{
"error": err,
"incident_id_param": gctx.Param("incident_id"),
})
gctx.AbortWithStatus(http.StatusBadRequest)
return
}
// Manual authentication and authorization
org, err := authn.Organisation(gctx, h.db)
if err != nil {
log.Error(ctx, errors.Wrap(err, "error authenticating organisation"))
gctx.AbortWithStatus(http.StatusInternalServerError)
return
}
// Database query
incident, err := h.db.ReadIncidentByExternalID(ctx, org.ID, externalIncidentID)
if err != nil {
log.Error(ctx, errors.Wrap(err, "failed to get incident"))
gctx.AbortWithStatus(http.StatusInternalServerError)
return
}
if incident == nil {
gctx.AbortWithStatus(http.StatusNotFound)
return
}
// Manual serialization with parallel data enrichment
apiIncident := &APIIncident{Incident: incident, Roles: []*APIIncidentRole{}}
// Concurrent enrichment of related data
g, ctx := saferrgroup.WithContext(ctx)
g.Go(func() error {
// Fetch and serialize severity
severity, err := h.db.ReadSeverityByID(ctx, incident.OrganisationID, incident.SeverityID)
if err != nil {
return errors.Wrap(err, "failed to read severity")
}
apiIncident.Severity = &APIIncidentSeverity{Severity: severity, Slug: severity.Slug()}
return nil
})
g.Go(func() error {
// Fetch and serialize reporter
reporter, err := h.userSvc.ReadUserByID(dbctx.NewContext(ctx, h.db), incident.OrganisationID, incident.ReporterID)
if err != nil {
return errors.Wrap(err, "failed to read reporter")
}
apiReporter, err := serialiseAPIIncidentUser(ctx, reporter)
if err != nil {
return errors.Wrap(err, "failed to serialise reporter")
}
apiIncident.Reporter = apiReporter
return nil
})
if err := g.Wait(); err != nil {
log.Error(ctx, errors.Wrap(err, "error serialising API incident"))
gctx.AbortWithStatus(http.StatusInternalServerError)
return
}
// Manual JSON response
gctx.JSON(200, &APIIncidentShowResponse{
Incident: apiIncident,
})
}
As you can see, not pretty.
When Lawrence joined the company, he saw this and one of the first things that he decided to implement was incorporating, and building upon, Goa. At its core, Goa is a design-first API framework. Unlike traditional frameworks where you start by writing handlers and wiring them up manually, with Goa you describe your API in a Domain Specific Language (DSL), and it generates all the boilerplate for you.
You begin by defining your API as a contract: endpoints, payloads, responses, errors, types, and validations which you declare in a structured, strongly typed format. Goa then turns that into production-ready Go code.
In our showIncidents example, this is all that we would use to define the structure:
var Incident = Type("Incident", func() {
Attribute("id", String, "Unique identifier")
Attribute("slack_channel", String, "Slack channel ID")
Attribute("severity", String, func() {
Enum("low", "medium", "high", "critical")
})
Attribute("created_at", String, func() {
Format(FormatDateTime)
})
...// We can define more attributes if we want
Required("id", "slack_channel", "severity", "created_at")
})
var _ = Service("incident", func() {
Method("ListIncidents", func() {
Result(ArrayOf(Incident))
HTTP(func() {
GET("/incidents")
Response(StatusOK)
})
})
})
Something we can agree is much nicer to look at.
That design tells Goa everything it needs to know: the expected response structure, required fields, type constraints (like datetime format and enum values), and HTTP details.
From this design, Goa generates several key components:
1. Transport Layer - HTTP handlers, routing logic, request decoding, response encoding, and error handling, all built on Go's standard net/http
package:
*// In api/gen/http/incident/server/server.go*
func (s *IncidentServer) listIncidents(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
*// Generated: HTTP method validation*
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
*// Generated: Call your service interface*
res, err := s.incident.ListIncidents(ctx)
if err != nil {
*// Generated: Error handling with proper HTTP status codes*
switch v := err.(type) {
case *goa.ServiceError:
http.Error(w, v.Message, v.StatusCode())
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
*// Generated: JSON response encoding*
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
2. Validation - Goa turns all the constraints from your DSL (Required, Format, Enum) into concrete validation code that runs before your business logic:
func (i *Incident) Validate() error {
var err error
if i.ID == "" {
err = goa.MergeErrors(err, goa.MissingFieldError("id", "missing required field"))
}
// Generated enum validation
if !(i.Severity == "low" || i.Severity == "medium" || i.Severity == "high" || i.Severity == "critical") {
err = goa.MergeErrors(err, goa.InvalidEnumValueError("severity", i.Severity, []interface{}{"low", "medium", "high", "critical"}))
}
// ... Do the same for all attributes
return err
}
3. Typed service interfaces- For each service you define, Goa creates a Go interface representing the contract which you can implement separate from the HTTP details:
*// Generated Go types*
type Incident struct {
ID string `json:"id"`
SlackChannel string `json:"slack_channel"`
Severity string `json:"severity"`
CreatedAt string `json:"created_at"`
}
*// Generated service interface - YOU implement this*
type Service interface {
ListIncidents(ctx context.Context) ([]*Incident, error)
}
4. OpenAPI Documentation - now since Goa has a complete understanding of your API's shape, it generates accurate OpenAPI 3.0 specs automatically:
5. Basic Go clients - Goa also generates Go clients with the same types and validation logic
*// In api/gen/http/incident/client/client.go*
type Client struct {
cli doer
encoder func(*http.Request) goahttp.Encoder
decoder func(*http.Response) goahttp.Decoder
scheme string
host string
}
func (c *Client) ListIncidents(ctx context.Context) ([]*incident.Incident, error) {
var path strings.Builder
path.WriteString("/incidents")
url := &url.URL{Scheme: c.scheme, Host: c.host, Path: path.String()}
req, err := http.NewRequest("GET", url.String(), nil)
if err != nil {
return nil, goahttp.ErrInvalidURL("incident", "ListIncidents", url.String(), err)
}
resp, err := c.cli.Do(req.WithContext(ctx))
if err != nil {
return nil, goahttp.ErrRequestError("incident", "ListIncidents", err)
}
return c.DecodeListIncidentsResponse(resp)
}
After all of this generated code, your only job is to implement the service interface. The interface is already hooked up to the transport layer that we saw it generating above, so your code receives fully validated inputs and returns typed results:
type incidentService struct {
db *sql.DB
}
func (s *incidentService) ListIncidents(ctx context.Context) ([]*Incident, error) {
*// Your business logic - fetch from database*
incidents, err := s.db.Query(`
SELECT id, slack_channel, severity, created_at
FROM incidents
ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
return incidents, nil
}
While standard Goa gives us an excellent foundation, we don't use its generated clients in production. Why?
Goa's clients are too generic and verbose. We need specialised clients for the different types of API’s that we have:
This is an example of Goa’s default client:
*// Goa's default client - verbose and clunky*
client := incident.NewClient("http", "localhost:8080", http.DefaultClient, goahttp.RequestEncoder, goahttp.ResponseDecoder)
payload := &incident.ListIncidentsPayload{}
incidents, err := client.ListIncidents(ctx, payload)
So for us, instead of running the isolated Goa script to generate the files, when we create any DSL, we simply run our make generate-api
command, which runs a series of events after the Goa script that creates the auto-generated boilerplate.
The first thing it does is enhance Goa's OpenAPI output. We pretty-print the JSON for better code reviews, merge information from multiple spec versions, and create filtered variants for different audiences (internal APIs, mobile-specific endpoints, and public APIs). This part is really important for what comes next.
We use these enhanced OpenAPI specifications to generate multiple specialised client libraries. Our generation pipeline automatically creates perfectly typed TypeScript interfaces that, for example, our frontend can import:
import {
type Incident,
IncidentSeverityEnum
} from "@incident-io/api";
*// Perfect type safety*
const incident: Incident = {
id: "inc_12345",
slack_channel: "C0123456",
severity: IncidentSeverityEnum.High, *// Type-safe enum*
created_at: "2024-06-12T09:23:00Z",
};ading } = useAPI("incidentListIncidents", {});
if (data) {
data.forEach((incident: Incident) => {
console.log(incident.severity); *// ✅ TypeScript knows this exists*
console.log(incident.nonExistentField); *// ❌ Compile error!*
*// Enum validation at compile time*
if (incident.severity === IncidentSeverityEnum.Critical) {
alert("Critical incident!");
}
});
}
This means the frontend gets compile-time safety for every API interaction:
const IncidentCard = ({ incident }: { incident: Incident }) => {
return (
<ContentBox className="p-4">
<LabeledValue
label="ID"
value={incident.id} *// ✅ TypeScript knows this is a string*
/>
<LabeledValue
label="Severity"
value={
<Badge
theme={getSeverityTheme(incident.severity)} *// ✅ Type-safe enum*
size={BadgeSize.Small}
>
{incident.severity}
</Badge>
}
/>
<LabeledValue
label="Created"
value={
<TimezoneAwareDateTime
timestamp={incident.created_at} *// ✅ Knows this is datetime string*
/>
}
/>
</ContentBox>
);
};
As well the clients for the front-end, we can generate separate mobile clients with only the endpoints mobile apps need, keeping them lean as well as Go test clients for integration testing. For example:
import { type IncidentMobile } from "@incident-io/mobile-api";
// Mobile-specific type with only essential fields
const incidents = await mobileClient.incidents.list();
incidents.forEach((incident: IncidentMobile) => {
// Mobile client only includes fields mobile actually needs
console.log(incident.id);
console.log(incident.severity);
console.log(incident.title);
// Fields excluded from mobile client to keep it lean:
// console.log(incident.slack_channel); // ❌ mobile API doesn't need Slack integration
....
});
}
This means when you add a new field to an API in the Goa design, a single make generate-api
automatically updates the server code, regenerates the OpenAPI docs, and pushes that change through to fully typed frontend code — all without any manual intervention. Your react components get compile-time errors if they try to use the API incorrectly, and your mobile apps only see the endpoints they're allowed to use.
For me as an intern, this setup meant the first project I worked on didn’t require me to recap my networking notes and get stuck in weeds of boilerplate. Instead I could focus on understanding how our product works and build features that matter. This is fundamentally what helps us move faster.
If this sounds like something you would want to get involved in, we are hiring at incident.io/careers
Wondering what it takes to operate beyond a Senior Engineer level? Go beyond checklists and explore the core qualities of a true technical leader, from the multiplier effect to genuine grit and drive.
Hear from James - one of our recent joiners in the AI team - why did they choose incident.io, and what do they think of us now they are here?
This is the story of how incident.io drastically cut down their code deployment times, detailing their journey from slow, inefficient CI/CD pipelines to achieving near "five-minute deploys" through strategic changes to their infrastructure and caching mechanisms.
Ready for modern incident management? Book a call with one of our experts today.