Saltar al contenido principal

Application Layer (Use-cases, Hooks & Mutations)

La capa Application es el cerebro operativo del frontend.

Es la capa que:

  • conecta la UI con el Domain y la Infrastructure
  • coordina flujos
  • decide qué pasa y en qué orden
  • maneja estados de ejecución (loading, error, success)

👉 Aquí vive la lógica de orquestación, no la lógica de negocio pura ni la UI.

No va aquí

  • JSX / componentes visuales (eso es UI).

  • Lógica de negocio “pura” (eso es Domain: reglas, invariantes, validaciones duras).

  • Detalles de red/SDKs/HTTP (eso es Infrastructure).


application/
├── use-cases/
├── queries/
└── mutations/

Tipos de archivos en Application

1. Queries (React Query – lectura)

Qué son

Hooks basados en React Query que exponen datos listos para UI.

Responsabilidad

Leer datos

Manejar cache

Exponer isLoading, error, data

Regla: deben devolver Domain models, no DTOs crudos.

Ejemplo

// src/features/user-profile/application/queries/useGetMe.query.ts
import { useQuery } from '@tanstack/react-query';
import UserService from '@/infrastructure/user';

export const meQuery = {
key: () => ['me'] as const,
queryFn: () => UserService.getMe(),
};

export function useGetMe() {
return useQuery({
queryKey: meQuery.key(),
queryFn: meQuery.queryFn,
});
}

// src/features/user-profile/application/queries/useGetUserByHandle.query.ts
import { useQuery } from '@tanstack/react-query';
import UserService from '@/infrastructure/user';

export const userByHandleQuery = {
key: (handle?: string) => ['user', handle] as const,
queryFn: (handle: string) => UserService.getUser(handle),
};

export function useGetUserByHandle(handle?: string) {
return useQuery({
queryKey: userByHandleQuery.key(handle),
enabled: !!handle,
queryFn: () => userByHandleQuery.queryFn(handle!),
});
}

2.Mutations

Responsabilidad

Hooks basados en React Query para realizar modificaciones en la base de datos

  • definir mutationFn

  • manejar invalidaciones (invalidateQueries)

  • devolver mutateAsync / isPending / error

// src/features/post-replies/application/mutations/useCreateReply.mutation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import PostService from '@/infrastructure/post';

export type CreateReplyInput = { postId: string; replyId: string };

export function useCreateReply() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateReplyInput) => PostService.createReply(input),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ['post', vars.postId] });
},
});
}

// src/features/post-replies/application/mutations/useSaveImage.mutation.ts
import { useMutation } from '@tanstack/react-query';
import MediaService from '@/infrastructure/media';

export function useSaveImage() {
return useMutation({
mutationFn: (file: File) => MediaService.saveImage(file),
});
}

// src/features/post-replies/application/mutations/useCreatePost.mutation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import PostService from '@/infrastructure/post';

export type CreatePostInput = { message: string; imageId?: string };

export function useCreatePost() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreatePostInput) => PostService.createPost(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['feed'] });
},
});
}

3. Use-cases

Qué son: Custom Hooks que representan una acción del usuario o un flujo (“Conectar retailer”, “Crear dashboard”, “Pagar”, etc.).

Regla: un use-case puede usar varias queries/mutations internas y devolver una API simple para la UI.

Responsabilidad

  • Orquestar domain + infrastructure
  • Validar reglas de alto nivel
  • Retornar resultados explícitos
info

💡 Punto clave: Inyección de dependencias en use-cases

Ejemplo de use-case que unifca query y mutations anteriores

// src/features/post-replies/application/use-cases/replyToPost.usecase.ts
//
// Use-case: Reply to a post (create a reply post, optionally upload an image, then link it as a reply)
//
// Key ideas (per our Application layer rules):
// - The use-case contains flow/orchestration (not UI, not raw API details).
// - Dependencies are injected as a "dependencies object" built from Infrastructure services.
// - The use-case does NOT import Infrastructure directly.
// - The hook that injects dependencies lives next to this file (see useReplyToPost.ts).
//

// Nota: Este archivo incluye:
// 1) `dependencies` (conoce Infrastructure)
// 2) `replyToPostUseCase` (puro, sin importar Infrastructure)
// 3) `useReplyToPost` (hook de inyección)

// src/features/post-replies/application/use-cases/useReplyToPost.usecase.ts
import type { User } from '@/shared/domain/user/model';
import type { Post } from '@/shared/domain/post/model';
import type { Image } from '@/shared/domain/media/model';

import { useGetMe } from '../queries/useGetMe.query';
import { useGetUserByHandle } from '../queries/useGetUserByHandle.query';
import { useSaveImage } from '../mutations/useSaveImage.mutation';
import { useCreatePost } from '../mutations/useCreatePost.mutation';
import { useCreateReply } from '../mutations/useCreateReply.mutation';

// Domain rules (shared)
import { canUserPost } from '@/shared/domain/user/logic';

export type ReplyToPostInput = {
postId: string;
recipientHandle: string;
message: string;
files?: File[] | null;
};

export const ReplyToPostErrors = {
NotAuthenticated: 'You must be logged in.',
TooManyPosts: 'You have reached the maximum number of posts per day.',
RecipientNotFound: 'The user you want to reply to does not exist.',
AuthorBlockedByRecipient:
"You can't reply to this user. They have blocked you.",
UnknownError: 'An unknown error occurred. Please try again later.',
} as const;

export type ReplyToPostResult =
| { ok: true }
| {
ok: false;
error: (typeof ReplyToPostErrors)[keyof typeof ReplyToPostErrors];
};

type Dependencies = {
me: User | undefined;
recipient: User | null | undefined;
saveImage: (file: File) => Promise<Image>;
createPost: (input: { message: string; imageId?: string }) => Promise<Post>;
createReply: (input: { postId: string; replyId: string }) => Promise<void>;
};

// Use-case "puro": no importa Infrastructure, solo opera con dependencias ya resueltas
export async function replyToPostUseCase(
input: ReplyToPostInput,
deps: Dependencies
): Promise<ReplyToPostResult> {
const { me, recipient, saveImage, createPost, createReply } = deps;

if (!me) return { ok: false, error: ReplyToPostErrors.NotAuthenticated };
if (!canUserPost(me))
return { ok: false, error: ReplyToPostErrors.TooManyPosts };

if (!recipient)
return { ok: false, error: ReplyToPostErrors.RecipientNotFound };
if (recipient.blockedUserIds?.includes(me.id)) {
return { ok: false, error: ReplyToPostErrors.AuthorBlockedByRecipient };
}

try {
let imageId: string | undefined;
const file = input.files?.[0];
if (file) {
const image = await saveImage(file);
imageId = image.id;
}

const replyPost = await createPost({ message: input.message, imageId });

await createReply({ postId: input.postId, replyId: replyPost.id });

return { ok: true };
} catch {
return { ok: false, error: ReplyToPostErrors.UnknownError };
}
}

export function useReplyToPost(params: { recipientHandle: string }) {
const me = useGetMe();
const recipient = useGetUserByHandle(params.recipientHandle);

const saveImage = useSaveImage();
const createPost = useCreatePost();
const createReply = useCreateReply();

return {
mutateAsync: (input: ReplyToPostInput) =>
replyToPostUseCase(input, {
me: me.data,
recipient: recipient.data,
saveImage: saveImage.mutateAsync,
createPost: createPost.mutateAsync,
createReply: createReply.mutateAsync,
}),

// Loading unificado (como el post)
isLoading:
me.isLoading ||
recipient.isLoading ||
saveImage.isPending ||
createPost.isPending ||
createReply.isPending,

// Error de "dependencias base" (queries)
isError: me.isError || recipient.isError,
};
}

Como se usa lo que creeamos

import { useState } from "react";

import { useGetMe } from "@/application/queries/get-me";
import { useReplyToShout } from "@/application/reply-to-shout";
import { LoginDialog } from "@/components/login-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { isAuthenticated } from "@/domain/me";

...

export function ReplyDialog({
recipientHandle,
children,
shoutId,
}: ReplyDialogProps) {
const [open, setOpen] = useState(false);
const [replyError, setReplyError] = useState<string>();
const replyToShout = useReplyToShout({ recipientHandle });
const me = useGetMe();

if (me.isError || !isAuthenticated(me.data)) {
return <LoginDialog>{children}</LoginDialog>;
}

async function handleSubmit(event: React.FormEvent<ReplyForm>) {
event.preventDefault();

const message = event.currentTarget.elements.message.value;
const files = Array.from(event.currentTarget.elements.image.files ?? []);

const result = await replyToShout.mutateAsync({
recipientHandle,
message,
files,
shoutId,
});

if (result.error) {
setReplyError(result.error);
} else {
setOpen(false);
}
}

...

return (
<Dialog open={open} onOpenChange={setOpen}>
{/* the rest of the component */}
</Dialog>
);
}

🧪 Testing de esta capa

Para ver lineamientos, alcance y ejemplos de pruebas del Application layer, consulta:

👉 /docs/frontend/quality/testing/testing-by-layer/application-test