web-feature-services
Como escrever services de feature no web do Smaug — objeto const de arrow functions async, apiClient, payload como tipo inline, retorno anotado, endpoints literais sem /api e mapeamento verbo→método H
SKILL.md.claude/skills/web-feature-services/SKILL.md
GitHubService de feature
Quando usar esta skill
Ao criar/alterar web/features/<f>/services/<Feature>Service.ts. É a única camada do front que
fala com a rede.
Anatomia canônica
web/features/despesas/services/DespesasService.ts:
import { apiClient } from "@/infra/api-client";
import type {
OneTimeExpense,
InstallmentExpense,
RecurringExpense,
RecurringExpenseVersion,
} from "../types";
export const DespesasService = {
// One-time expenses
getOneTime: async (): Promise<OneTimeExpense[]> => {
const { data } = await apiClient.get("/expenses/one-time");
return data;
},
createOneTime: async (payload: {
description: string;
amount: number;
categoryId: string;
competenceYear: number;
competenceMonth: number;
}): Promise<OneTimeExpense> => {
const { data } = await apiClient.post("/expenses/one-time", payload);
return data;
},
updateOneTime: async (
id: string,
payload: {
description: string;
amount: number;
categoryId: string;
competenceYear: number;
competenceMonth: number;
},
): Promise<OneTimeExpense> => {
const { data } = await apiClient.put(`/expenses/one-time/${id}`, payload);
return data;
},
deleteOneTime: async (id: string): Promise<void> => {
await apiClient.delete(`/expenses/one-time/${id}`);
},
Regras
Export é um
constobjeto de arrow functions async, nomeado<Feature>Serviceem PascalCase, igual ao nome do arquivo. Sem classe, semdefault, sem barril.Importa
{ apiClient }de@/infra/api-client(alias, mesmo de dentro da feature) e os tipos por caminho relativofrom "../types".Tipo de retorno sempre anotado explicitamente:
Promise<T>,Promise<T[]>,Promise<void>.O payload é um tipo estrutural inline no parâmetro, e o parâmetro se chama
payload(datasó emAuthService.register). Para update/patch a assinatura é(id: string, payload: {...}).Endpoints são strings literais, sem
/api, em kebab-case, com template literal para ids:/expenses/one-time,/expenses/installment,/expenses/recurring/${id}/terminate,/revenues/fixed,/auth/login,/users/me. Não existe arquivo de constantes de rota. O/apido repasse é obaseURLdoapiClient(skillweb-infra) — escrevê-lo no service daria/api/api/....Mapeamento verbo→método:
Operação Método criar postsubstituição total putatualização parcial / operação de domínio patchexcluir delete, devolvendoPromise<void>semreturnAções de domínio viram sub-rota:
terminateRecurring→patch("/expenses/recurring/${id}/terminate").Nomes de método:
getX,createX,updateX,deleteX(o service usadelete; o hook renomeia pararemove), mais verbos de domínio comoaddRecurringVersion,terminateRecurring.Seções separadas por comentário de linha:
// One-time expenses,// Installment expenses,// Recurring expenses.Genérico no
apiClientsó quando a resposta tem forma diferente do tipo de domínio exportado, e essa forma é umainterfacelocal não exportada no próprio arquivo do service:interface ExpenseQueryResponse { ... } const [revRes, expRes] = await Promise.all([ apiClient.get<RevenueQueryResponse>("/revenues", { params }), apiClient.get<ExpenseQueryResponse>("/expenses", { params }), ]);Transformação pesada fica em funções privadas acima do objeto exportado (
fetchMonth,toEntries,groupByMonthemHistoricoService.ts) — o objeto exportado permanece fino.O service não trata erro: deixa o axios rejeitar. Quem traduz é o hook, via
getApiErrorMessage(skillweb-infra).
O service de autenticação
web/features/auth/services/AuthService.ts segue a mesma anatomia — e não sabe de sessão: devolve
o corpo e quem guarda o token é o hook (useAuth/useRegister, via setSession):
export const AuthService = {
register: async (data: RegisterInput): Promise<AuthSessionResponse> => {
const { data: session } = await apiClient.post("/auth/register", data);
return session;
},
login: async (payload: LoginInput): Promise<AuthSessionResponse> => {
const { data } = await apiClient.post("/auth/login", payload);
return data;
},
logout: async (): Promise<void> => {
await apiClient.post("/auth/logout");
},
logoutAll: async (): Promise<void> => {
await apiClient.post("/auth/logout-all");
},
getMe: async (): Promise<User> => {
const { data } = await apiClient.get("/users/me");
return data;
},
};
- Passa pelo
apiClientcomo todo service: o Bearer entra sozinho (logoutAll,getMe) e o cookiesmaug_refreshvai sozinho pela mesma origem (logout). - 401 em
/auth/*não dispara renovação no interceptor — o service só propaga; quem decide é o hook. - A renovação (
POST /auth/refresh) não é método do service: vive eminfra/auth-refresh.ts, com axios puro, porque o próprio interceptor doapiClientdepende dela.
Checklist
-
export const <Feature>Service = { ... }com arrow functions async. - Todo método com tipo de retorno anotado.
- Payload como tipo inline no parâmetro
payload. - Endpoint literal correto (confira o método HTTP contra
server/src/presentation/routes/). - Transformação complexa em função privada acima do objeto.
- Teste
<Feature>Service.test.tsassertando assinatura da requisição, corpo e código de erro (skillweb-testes) —PUTvsPATCHe/:idvs/:id/terminatesão invisíveis para o TypeScript e é exatamente isso que o teste protege.
Não faça
- Não use
try/catchpara engolir erro — o hook precisa dele. - Não construa mensagem de erro aqui.
- Não use classe nem
export default. - Não leia cookie/sessão nem monte
Authorization: o Bearer é injetado pelo interceptor doapiClient, e a sessão é guardada pelo hook. - Não coloque
/apino caminho.
Inconsistências conhecidas
DadosService.downloadExportusaconst response = await apiClient.get(...)(precisa doresponse.headersalém do corpo); os demais desestruturamconst { data }. A desestruturação é a forma padrão — useresponsesó quando precisar de algo além do corpo.AuthService.registerrecebe o parâmetrodata(nãopayload) e por isso renomeia na desestruturação (const { data: session }). Em código novo, chame o parâmetro depayload.AuthServicetipa o payload com interfaces nomeadas de../types(RegisterInput,LoginInput) em vez de tipo inline — reaproveitadas pelos hooks e pelo formulário. Nos demais services, siga o tipo inline.