Skip to content

Commit a6522b8

Browse files
committed
refactor: improve error handling and domain architecture
- Refactor domain errors: unified error patterns across metadata, link, and bff services - Add infrastructure DTOs for consistent MQ error handling with retry logic - Improve MQ consumers: all errors go to DLQ, add raw event archiving - Split CQRS handlers into separate files (handle_add, handle_update, handle_delete) - Enhance Links collection: rename field to items, add nil-safety, return copies - Extract parser use cases into separate files (get.go, set.go) - Add WithDetail/WithTitle helpers to BFF error domain - Fix error normalization: use domainerrors.Normalize consistently - Update LinkBuilder to return domain errors instead of generic errors
1 parent 171955a commit a6522b8

File tree

107 files changed

+1553
-920
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

107 files changed

+1553
-920
lines changed

.cursor/rules/go-microservices.mdc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ You are an expert in Go, microservices architecture, and clean backend developme
2727

2828
- Write **short, focused functions** with a single responsibility.
2929
- Always **check and handle errors explicitly**, using wrapped errors for traceability ('fmt.Errorf("context: %w", err)').
30+
- **Never return function calls directly**—assign results (especially errors) to variables, handle them, and then return to keep control flow explicit.
3031
- Avoid **global state**; use constructor functions to inject dependencies.
3132
- Leverage **Go's context propagation** for request-scoped values, deadlines, and cancellations.
3233
- Use **goroutines safely**; guard shared state with channels or sync primitives.

.golangci.yml

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,18 @@ linters:
1010
# Enable every linter, then prune the noisy ones
1111
default: all
1212
disable:
13-
- importas # noisy / incorrect results
14-
- exhaustruct # hard to use with current codebase
15-
- godox # incorrect results in our project
13+
- exhaustruct # hard to use with current codebase
14+
- godox # incorrect results in our project
1615
- depguard
1716
- gochecknoglobals
1817
- wsl # deprecated
18+
- gomoddirectives # monorepo boundaries rely on replace directives
1919

2020
exclusions:
2121
paths:
22-
- "ops" # Ops manifests, etc.
23-
- ".*mocks.+" # Generated mocks
24-
- "wire_gen.go" # Wire‑generated
22+
- "ops" # Ops manifests, etc.
23+
- ".*mocks.+" # Generated mocks
24+
- "wire_gen.go" # Wire‑generated
2525
rules:
2626
- path: "(.+)_test.go"
2727
linters:
@@ -30,11 +30,10 @@ linters:
3030
- nilnil
3131
- gochecknoinits
3232
- err113
33-
generated: strict # Skip all generated files across linters/formatters
33+
generated: strict # Skip all generated files across linters/formatters
3434

3535
# Per‑linter tuning ---------------------------------------------------------
3636
settings:
37-
3837
wsl_v5:
3938
allow-first-in-block: true
4039
allow-whole-block: false
@@ -164,12 +163,12 @@ linters:
164163
arguments: ["^[a-zA-Z][a-zA-Z0-9_]{0,}$"]
165164
- name: argument-limit
166165
severity: warning
167-
arguments: [4]
166+
arguments: [6]
168167
- name: function-length
169168
severity: warning
170169
arguments: [75, 0]
171-
- name: enforce-switch-style # new in revive v1.11
172-
severity: warning # requires a default as last case
170+
- name: enforce-switch-style # new in revive v1.11
171+
severity: warning # requires a default as last case
173172
# Disabled / noisy checks
174173
- name: var-naming
175174
disabled: true

boundaries/bff/internal/domain/errors/errors.go

Lines changed: 109 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -31,45 +31,36 @@ type Error struct {
3131
Cause error // underlying technical cause (if any)
3232
}
3333

34-
// ============================================================
35-
// Behavior
36-
// ============================================================
37-
38-
// Error implements the standard error interface.
39-
func (e *Error) Error() string {
40-
if e == nil {
41-
return "<nil>"
34+
var (
35+
// ErrSessionNotFound is a sentinel error for missing user sessions.
36+
ErrSessionNotFound = &Error{
37+
Code: CodeSessionNotFound,
38+
Title: "Session expired",
39+
Detail: "Session expired. Please sign in again.",
40+
Action: "LOGIN",
4241
}
43-
if e.Cause == nil {
44-
return fmt.Sprintf("[%s] %s: %s", e.Code, e.Title, e.Detail)
42+
// ErrUserNotIdentified is a sentinel error for anonymous requests.
43+
ErrUserNotIdentified = &Error{
44+
Code: CodeUserNotIdentified,
45+
Title: "User not identified",
46+
Detail: "Unable to resolve your account. Please sign in again.",
47+
Action: "LOGIN",
4548
}
46-
return fmt.Sprintf("[%s] %s: %s → cause: %v", e.Code, e.Title, e.Detail, e.Cause)
47-
}
48-
49-
// Unwrap allows integration with errors.Is / errors.As.
50-
func (e *Error) Unwrap() error {
51-
return e.Cause
52-
}
53-
54-
// WithCause returns a shallow copy with a new cause attached.
55-
// Keeps the domain semantics intact while layering infra or app details.
56-
func (e *Error) WithCause(cause error) *Error {
57-
if e == nil {
58-
return nil
49+
// ErrSessionMetadataMissing is a sentinel error for missing auth metadata.
50+
ErrSessionMetadataMissing = &Error{
51+
Code: CodeSessionMetadataMissing,
52+
Title: "Authentication metadata missing",
53+
Detail: "Request is missing authentication metadata.",
54+
Action: "LOGIN",
5955
}
60-
cp := *e
61-
cp.Cause = cause
62-
return &cp
63-
}
64-
65-
// IsCode checks if an error (possibly wrapped) matches a given domain code.
66-
func IsCode(err error, code string) bool {
67-
var de *Error
68-
if errors.As(err, &de) {
69-
return de.Code == code
56+
// ErrUnknown is a sentinel error for unexpected states.
57+
ErrUnknown = &Error{
58+
Code: CodeUnknown,
59+
Title: "Unexpected error",
60+
Detail: "Unexpected error occurred.",
61+
Action: "NONE",
7062
}
71-
return false
72-
}
63+
)
7364

7465
// ============================================================
7566
// Factory Functions (ubiquitous, intention-revealing)
@@ -110,3 +101,86 @@ func NewUnknown(detail string) *Error {
110101
Action: "NONE",
111102
}
112103
}
104+
105+
// ============================================================
106+
// Behavior
107+
// ============================================================
108+
109+
// Error implements the standard error interface.
110+
func (e *Error) Error() string {
111+
if e == nil {
112+
return "<nil>"
113+
}
114+
115+
if e.Cause == nil {
116+
return fmt.Sprintf("[%s] %s: %s", e.Code, e.Title, e.Detail)
117+
}
118+
119+
return fmt.Sprintf("[%s] %s: %s → cause: %v", e.Code, e.Title, e.Detail, e.Cause)
120+
}
121+
122+
// Unwrap allows integration with errors.Is / errors.As.
123+
func (e *Error) Unwrap() error {
124+
return e.Cause
125+
}
126+
127+
// Is allows errors.Is to compare domain errors by code only.
128+
func (e *Error) Is(target error) bool {
129+
if e == nil {
130+
return target == nil
131+
}
132+
133+
t, ok := target.(*Error)
134+
if !ok || t == nil {
135+
return false
136+
}
137+
138+
return e.Code == t.Code
139+
}
140+
141+
// WithCause returns a shallow copy with a new cause attached.
142+
// Keeps the domain semantics intact while layering infra or app details.
143+
func (e *Error) WithCause(cause error) *Error {
144+
if e == nil {
145+
return nil
146+
}
147+
148+
cp := *e
149+
cp.Cause = cause
150+
151+
return &cp
152+
}
153+
154+
// WithDetail returns a shallow copy with an overridden detail message.
155+
func (e *Error) WithDetail(detail string) *Error {
156+
if e == nil {
157+
return nil
158+
}
159+
160+
cp := *e
161+
cp.Detail = detail
162+
163+
return &cp
164+
}
165+
166+
// WithTitle returns a shallow copy with an overridden title.
167+
func (e *Error) WithTitle(title string) *Error {
168+
if e == nil {
169+
return nil
170+
}
171+
172+
cp := *e
173+
cp.Title = title
174+
175+
return &cp
176+
}
177+
178+
// IsCode checks if an error (possibly wrapped) matches a given domain code.
179+
func IsCode(err error, code string) bool {
180+
var de *Error
181+
if errors.As(err, &de) {
182+
return de.Code == code
183+
}
184+
185+
return false
186+
}

boundaries/bff/internal/domain/errors/errors_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,22 @@ func TestErrorConstructors(t *testing.T) {
4646
require.Equal(t, tt.wantTitle, err.Title)
4747
require.NotEmpty(t, err.Detail)
4848
require.Equal(t, tt.wantAction, err.Action)
49-
require.Nil(t, err.Cause)
49+
require.NoError(t, err.Cause)
5050
})
5151
}
5252
}
5353

5454
func TestNewUnknown(t *testing.T) {
5555
const detail = "unexpected I/O failure"
56+
5657
err := NewUnknown(detail)
5758

5859
require.NotNil(t, err)
5960
require.Equal(t, CodeUnknown, err.Code)
6061
require.Equal(t, "Unexpected error", err.Title)
6162
require.Equal(t, detail, err.Detail)
6263
require.Equal(t, "NONE", err.Action)
63-
require.Nil(t, err.Cause)
64+
require.NoError(t, err.Cause)
6465
}
6566

6667
func TestWithCause(t *testing.T) {

boundaries/bff/internal/infrastructure/http/controllers/cqrs/cqrs.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ package cqrs
33
import (
44
"net/http"
55

6+
link_cqrs "buf.build/gen/go/shortlink-org/shortlink-link-link/grpc/go/infrastructure/rpc/cqrs/link/v1/linkv1grpc"
67
"github.com/go-chi/chi/v5"
78
"google.golang.org/protobuf/encoding/protojson"
8-
9-
link_cqrs "buf.build/gen/go/shortlink-org/shortlink-link-link/grpc/go/infrastructure/rpc/cqrs/link/v1/linkv1grpc"
109
)
1110

1211
var jsonpb protojson.MarshalOptions
@@ -78,7 +77,6 @@ func (c *LinkCQRSController) GetLinksByCQRS(w http.ResponseWriter, r *http.Reque
7877
//
7978
// return
8079
// }
81-
8280
w.WriteHeader(http.StatusOK)
8381
_, _ = w.Write(nil) //nolint:errcheck
8482
}

boundaries/bff/internal/infrastructure/http/controllers/link/addLink.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ package link
33
import (
44
"net/http"
55

6-
"github.com/segmentio/encoding/json"
7-
86
v1 "buf.build/gen/go/shortlink-org/shortlink-link-link/protocolbuffers/go/infrastructure/rpc/link/v1"
7+
"github.com/segmentio/encoding/json"
98

109
"github.com/shortlink-org/shortlink/boundaries/link/bff/internal/infrastructure/http/api"
1110
"github.com/shortlink-org/shortlink/boundaries/link/bff/internal/infrastructure/http/controllers/link/dto"
@@ -15,6 +14,7 @@ import (
1514
func (c *Controller) AddLink(w http.ResponseWriter, r *http.Request) {
1615
// Parse request
1716
var request api.AddLink
17+
1818
err := json.NewDecoder(r.Body).Decode(&request)
1919
if err != nil {
2020
w.WriteHeader(http.StatusBadRequest)
@@ -42,6 +42,7 @@ func (c *Controller) AddLink(w http.ResponseWriter, r *http.Request) {
4242
}
4343

4444
w.WriteHeader(http.StatusCreated)
45+
4546
err = json.NewEncoder(w).Encode(response)
4647
if err != nil {
4748
c.log.Error(err.Error())

boundaries/bff/internal/infrastructure/http/controllers/link/deleteLink.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,5 @@ func (c *Controller) DeleteLink(w http.ResponseWriter, r *http.Request, hash str
1313
//
1414
// return
1515
// }
16-
1716
w.WriteHeader(http.StatusNoContent)
1817
}

boundaries/bff/internal/infrastructure/http/controllers/link/error.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@ import (
55
"strings"
66

77
"github.com/segmentio/encoding/json"
8+
"github.com/shortlink-org/go-sdk/auth/session"
89
"google.golang.org/genproto/googleapis/rpc/errdetails"
910
"google.golang.org/grpc/codes"
1011
"google.golang.org/grpc/status"
1112

12-
"github.com/shortlink-org/go-sdk/auth/session"
13-
1413
domainerrors "github.com/shortlink-org/shortlink/boundaries/link/bff/internal/domain/errors"
1514
)
1615

@@ -42,6 +41,7 @@ func ErrMessages(err error) *ErrorResponse {
4241
if !ok {
4342
// Not a gRPC status — wrap as unknown domain error
4443
domainErr := domainerrors.NewUnknown(err.Error())
44+
4545
return &ErrorResponse{
4646
Messages: []ErrorDetail{{
4747
Code: domainErr.Code,
@@ -60,11 +60,13 @@ func ErrMessages(err error) *ErrorResponse {
6060

6161
// Include any additional gRPC error details (for diagnostics)
6262
var grpcDetails []string
63+
6364
for _, d := range st.Details() {
6465
raw, err := json.Marshal(d)
6566
if err != nil {
6667
continue
6768
}
69+
6870
grpcDetails = append(grpcDetails, string(raw))
6971
}
7072

@@ -112,7 +114,7 @@ func mapStatusToResponse(st *status.Status) *domainerrors.Error {
112114
return domainerrors.NewUserNotIdentified()
113115
case strings.Contains(message, session.ErrMetadataNotFound.Error()):
114116
return domainerrors.NewSessionMetadataMissing()
115-
default:
117+
default:
116118
return domainerrors.NewUnknown(message)
117119
}
118120
}

boundaries/bff/internal/infrastructure/http/controllers/link/error_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@ package link
33
import (
44
"testing"
55

6+
"github.com/shortlink-org/go-sdk/auth/session"
67
"github.com/stretchr/testify/require"
78
"google.golang.org/genproto/googleapis/rpc/errdetails"
89
"google.golang.org/grpc/codes"
910
"google.golang.org/grpc/status"
1011

11-
"github.com/shortlink-org/go-sdk/auth/session"
12-
1312
domainerrors "github.com/shortlink-org/shortlink/boundaries/link/bff/internal/domain/errors"
1413
)
1514

boundaries/bff/internal/infrastructure/http/controllers/link/getLinks.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,15 @@ package link
33
import (
44
"net/http"
55

6-
"github.com/segmentio/encoding/json"
7-
86
v1 "buf.build/gen/go/shortlink-org/shortlink-link-link/protocolbuffers/go/infrastructure/rpc/link/v1"
7+
"github.com/segmentio/encoding/json"
98

109
"github.com/shortlink-org/shortlink/boundaries/link/bff/internal/infrastructure/http/api"
1110
)
1211

1312
// GetLinks - get links
1413
func (c *Controller) GetLinks(w http.ResponseWriter, r *http.Request, params api.GetLinksParams) {
1514
// TODO: add mapper for filter
16-
1715
request := &v1.ListRequest{}
1816

1917
if params.Cursor != nil {
@@ -48,6 +46,7 @@ func (c *Controller) GetLinks(w http.ResponseWriter, r *http.Request, params api
4846
}
4947

5048
w.WriteHeader(http.StatusOK)
49+
5150
err = json.NewEncoder(w).Encode(response)
5251
if err != nil {
5352
c.log.Error(err.Error())

0 commit comments

Comments
 (0)