Ready, steady, goa: our API setup

August 11, 2025 — 7 min read

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.

The norm, and why it doesn’t work for us

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.

The better way

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.

Goa’s magical output

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)
}

Your job

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
}

One step further

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:

  • Our Frontend needs TypeScript with perfect React integration
  • Our Mobile app needs a lean client with only mobile-relevant endpoints
  • The Test cases need ones optimised for testing patterns
  • External developers need our public-only endpoints

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

Picture of Shlok Shah
Shlok Shah
Product Engineering Intern
View more

See related articles

View all

So good, you’ll break things on purpose

Ready for modern incident management? Book a call with one of our experts today.

Signup image

We’d love to talk to you about

  • All-in-one incident management
  • Our unmatched speed of deployment
  • Why we’re loved by users and easily adopted
  • How we work for the whole organization