refactor(service): extract internal/service layer; migrate SignUp (Phase 1)#615
refactor(service): extract internal/service layer; migrate SignUp (Phase 1)#615lakhansamani wants to merge 1 commit into
Conversation
…SignUp (Phase 1)
Introduces internal/service as the home for transport-agnostic public-API
operations. Each method takes a RequestMetadata (host, IP, UA, raw request)
and returns a typed response plus a ResponseSideEffects bag that the
transport applies (today: cookies; later: redirects, trailing headers).
SignUp is the first migrated operation — chosen as the seam-proof because
it exercises every gin coupling the legacy resolvers had: GetHost,
GetIP/GetUserAgent, token issuance, session + mfa cookie writes,
verification email/SMS dispatch.
The GraphQL resolver in internal/graphql/signup.go becomes a thin adapter:
build RequestMetadata from gin.Context, call the service, apply side-effects.
This is the seam Phase 2's gRPC and gRPC-gateway REST handlers will reuse.
Supporting changes (all transport-agnostic mirrors of existing helpers,
with the gin wrappers delegating to them so behaviour is identical):
- parsers.GetHostFromRequest / GetAppURLFromRequest (raw *http.Request)
- cookie.BuildSessionCookies / BuildMfaSessionCookies (return []*http.Cookie)
Wiring:
- service.Provider constructed in cmd/root.go and integration_tests/test_helper.go
- graphql.Dependencies + http_handlers.Dependencies grow a ServiceProvider field
- http_handlers.GraphqlHandler passes it through to graphql.New
Known follow-up: TokenProvider.CreateAuthToken still takes *gin.Context
even though it doesn't use it. The service synthesizes a minimal shim
(`&gin.Context{Request: meta.Request}`); refactoring CreateAuthToken to take
*http.Request directly is a Phase 2 cleanup tracked by a TODO at the call site.
Tests:
- All 11 TestSignup sub-cases pass
- Full SQLite integration suite (71s) still green — no behaviour drift
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lakhansamani
left a comment
There was a problem hiding this comment.
Principal-engineer review — Phase 1 (refactor(service): extract internal/service)
Reviewed against feat/proto-skeleton. Diff is ~1.2k lines, mostly the SignUp move and the gin/http helper splits.
Strengths
- Seam shape is solid.
RequestMetadata+ResponseSideEffects+MetaFromGin/ApplyToGinis a clean transport adapter. TheBuild*Cookies/GetHostFromRequestmirrors are the right way to split — old gin wrappers are now thin shims, so call sites pay zero cost. - Cookie semantics are preserved exactly. Both
_sessionand_session_domainare emitted with the sameName/MaxAge/Path/Domain/Secure/HttpOnly/SameSiteshape; the"." + domainrule (skipped forlocalhost) is intact inBuildSessionCookies. SameSite policy for MFA (Lax-when-insecure / None-when-secure) is preserved verbatim. ApplyToGinis per-cookie SetSameSite-then-SetCookie — this is actually more correct than the old code, because future ops can return cookies with mixed SameSite without stomping each other.- Audit/IP/UA preserved.
meta.IPAddress/meta.UserAgentcaptured by value before the goroutine — same behavior as the inlineutils.GetIP(gc.Request)call. - Embedding pattern (
*config.Config+Dependencies) matchesgraphqlProvider/httpProviderexactly. Idiomatic for this codebase, no surprises. - gcShim claim is correct. Verified
auth_token.go:CreateAuthToken(gc, cfg)only forwards toCreateSessionToken/CreateAccessToken/CreateIDToken— none touchgc. The shim is safe today; the TODO is appropriate.
Issues
Important
-
service.Metais dead code in this PR.Providerinterface declaresMeta(ctx, meta) (*model.Meta, *ResponseSideEffects, error)andmeta.goimplements it, but nothing calls it — the GraphQLMeta()resolver still has the original signatureMeta(ctx context.Context) (*model.Meta, error)and was not migrated to delegate. Either (a) migrate the resolver in this PR for consistency with SignUp, or (b) dropMetafrom the interface and the file, defer to a later phase. As-is it's silent scope creep — the PR title/body promises only SignUp. -
No service-layer-direct tests. The whole point of extracting a transport-agnostic layer is to make it testable without spinning up gin.
TestSignupstill exercises the resolver path — fine as a regression net, but the seam itself is unverified. Bare minimum I'd want before Phase 2:service/signup_test.gocallingprovider.SignUpdirectly with a hand-builtRequestMetadataand stub providers (at least the happy email-verification + mobile-OTP + auth-token branches).cookie/cookie_test.goforBuildSessionCookiesandBuildMfaSessionCookies— these are now load-bearing helpers, and the"." + domain/localhostbranch is exactly the kind of thing a copy-paste in Phase 2 will get wrong.- The new
TestGetHostFromRequest/TestGetAppURLFromRequestare good, keep doing that.
-
RequestMetadata.Requestescape hatch will outlive its TODO. Documented as a "legacy" field forTokenProvider, but Phase 2 will introduce gRPC handlers that won't have a real*http.Request(or will need to synthesize one with empty headers). The gcShim trick works forCreateAuthTokenbecause that call doesn't read it, but the moment someone callsGetAccessToken(gc)from the service layer, gRPC breaks silently. Worth either (a) refactoringCreateAuthTokenin this PR to take*http.Request(the TODO promises a "Phase 2 cleanup, ~10 callers" — do it before gRPC lands, not after), or (b) explicit panic/log in the shim path whenmeta.Request == nilso gRPC fails loud instead of NPE.
Nits
ResponseSideEffects.AddCookiedoc says "safe on a zero-value receiver" — true for a non-nil*ResponseSideEffectspointing to a zero struct, but a nil pointer panics. Reword to "safe on a zero-value struct".- Email-verification branch returns
side, nilwheresideis empty — fine, but inconsistent with the early validation returns ofnil, nil, err. Pick one style. service.Newreturns(Provider, error)but never errors. Matches the codebase pattern, so OK — but the TODO// TODO - Add any validation hereshould either be filled in (validate non-nil deps + non-nil config) or removed.MetaFromGinreturns zeroRequestMetadata{}on nil gc/Request. Defensive but downstream code will silently produce nonsense (empty hostname, no audit IP/UA). Probably better to make it a programming error — caller should never pass nil.gcShimliteral&gin.Context{Request: meta.Request}creates a gin.Context with noengine,Keys, or middleware. If any helper down-the-stack callsgc.GetString/gc.Getit will return zero values silently. Document this constraint on the shim's TODO line, or wrap in a helpernewSyntheticGin(r *http.Request)that future readers can grep for.
Test coverage gaps (recap)
internal/service/signup_test.go— direct, no gininternal/cookie/cookie_test.go—BuildSessionCookies/BuildMfaSessionCookies(domain edge cases: localhost, single-label, IPv6, ports)internal/service/sideeffects_test.go—MetaFromGinwith realistic gin.Context,ApplyToGinwith mixed-SameSite cookies, nil-safety of both- A regression test that proves
Set-Cookieheaders on the SignUp HTTP response are byte-identical pre/post-refactor — easy to assert againsthttptestrecorder, and it's the one thing the existing test suite doesn't cover.
Verdict
Mergeable after addressing (1) and a sentence-level decision on (3). (2) ideally lands in this PR but I'd accept a follow-up issue tracking it before Phase 2 merges — these tests get harder to write once gRPC is layered on top. The refactor itself is faithful and the seam is well-chosen; SignUp was the right pick to prove it.
Summary
internal/serviceas the home for transport-agnostic public-API operations. Each method takesRequestMetadata(host, IP, UA, raw request) and returns a typed response plus aResponseSideEffectsbag the transport applies (today: cookies; later: redirects, trailing headers).Supporting cleanups (all transport-agnostic mirrors of existing helpers)
Wiring
Known follow-up
`TokenProvider.CreateAuthToken` still takes `*gin.Context` even though it doesn't read from it. The service synthesizes a minimal shim (`&gin.Context{Request: meta.Request}`) at the call site, with a TODO comment. Refactoring `CreateAuthToken` to take `*http.Request` directly is a Phase 2 cleanup (~10 callers).
Test plan
Follow-ups
🤖 Generated with Claude Code