Error handling

Go: explicit + wrapped. React: error boundary + toast.

6 minmedium

Errors are normal; how you handle them is what makes the code production-ready. Grit has one pattern for Go (explicit + wrapped) and one for React (error boundary + toast). Both close the loop so no error is silently swallowed.

Go side — explicit, wrapped, never ignored

apps/api/internal/services/user.go
func (s *UserService) Create(ctx context.Context, in CreateUserInput) (*User, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hashing password: %w", err)
}
user := &User{Email: in.Email, PasswordHash: hashed}
if err := s.db.Create(user).Error; err != nil {
return nil, fmt.Errorf("creating user: %w", err)
}
return user, nil
}

The three rules:

  • Every error is checked. Never _ := someFunc().
  • Errors are wrapped with context. fmt.Errorf("doing X: %w", err). The %w makes it unwrappable upstream (with errors.Is / errors.As).
  • Handlers convert errors to HTTP responses. Services return errors; handlers map them to status codes via a tiny helper.

The handler-side helper

apps/api/internal/handlers/user.go
func (h *UserHandler) Create(c *gin.Context) {
var in services.CreateUserInput
if err := c.ShouldBindJSON(&in); err != nil {
respond.Error(c, 422, "VALIDATION_ERROR", err)
return
}
user, err := h.svc.Create(c.Request.Context(), in)
if err != nil {
respond.Error(c, 500, "INTERNAL_ERROR", err)
return
}
respond.Created(c, user, "User created")
}

respond is a tiny package that shapes the envelope you learned in the last lesson.

React side — error boundary + toast

apps/web/components/use-create-user.ts
export function useCreateUser() {
return useMutation({
mutationFn: api.users.create,
onSuccess: () => toast.success("User created"),
onError: (err: ApiError) => {
if (err.code === "VALIDATION_ERROR") {
// form-level handling — see details.email etc.
return
}
toast.error(err.message || "Something went wrong")
},
})
}

Above the page, an <ErrorBoundary> catches anything the mutations / queries don't — including render-time crashes. Users see a friendly fallback, not a white screen.

Never log and forget. If you catch an error and decide to continue, that's a deliberate choice — write a comment explaining why. Most errors in services should bubble up to the handler.

Quick check

A teammate writes: `db.Save(&user)` without checking the returned error. What's the right code review feedback?

Try it

Trigger every error path in a real request. With the API running, use curl or the OpenAPI docs to hit:

  1. POST /api/auth/login with no body — capture the 422 response in notes.md.
  2. POST /api/auth/login with valid email + wrong password — capture the 401.
  3. GET /api/users/00000000-0000-0000-0000-000000000000 — capture the 404.

You should see Shape 3 in all three cases.

What's next

Chapter 4 — Code generation. The most fun command in Grit. grit generate resource Product writes 7 files and wires them all together. We tour them line by line.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub