Estándares de Código — Plataforma Imagy
Backend (.NET 10)
Estructura de Solución
Cada servicio sigue Clean Architecture con 5 proyectos:
Imagy.{Domain}.Api/ → Controllers, middleware, DI
Imagy.{Domain}.Application/ → Commands, queries, handlers, validators, DTOs
Imagy.{Domain}.Domain/ → Entidades, value objects, interfaces, domain services
Imagy.{Domain}.Infrastructure/ → EF Core, Dapper, MassTransit, HTTP clients
Imagy.{Domain}.Migrations/ → DbUp scripts SQL
Imagy.{Domain}.Tests/ → Unit + integration testsEntidades Base
csharp
// Entidad base — todos los agregados heredan de aquí
public abstract class BaseEntity
{
public Guid Id { get; protected set; } = Guid.NewGuid();
public DateTime CreatedAt { get; protected set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; protected set; }
}
// Entidad multi-tenant — la mayoría de entidades de negocio
public abstract class TenantEntity : BaseEntity
{
public Guid TenantId { get; protected set; }
}
// Value Objects — inmutables, comparación por valor
public record Money(decimal Amount, string Currency);
public record DateRange(DateTime Start, DateTime End);Commands y Queries (CQRS con MediatR)
csharp
// Command → escritura → EF Core → Primary DB
public record CreateCreditProductCommand(
string Code,
string Name,
decimal MinAmount,
decimal MaxAmount,
decimal InterestRate,
int MinTermDays,
int MaxTermDays
) : IRequest<Result<CreditProductCreatedResponse>>;
// Query → lectura → Dapper → Read Replica
public record GetCreditProductsQuery(
Guid TenantId,
bool? IsActive,
int Page = 1,
int PageSize = 20
) : IRequest<PagedResult<CreditProductDto>>;Handlers
csharp
public class CreateCreditProductHandler
: IRequestHandler<CreateCreditProductCommand, Result<CreditProductCreatedResponse>>
{
private readonly ICreditProductRepository _repository;
private readonly IUnitOfWork _unitOfWork;
private readonly IPublishEndpoint _publishEndpoint;
private readonly IIdentityContext _identity;
public async Task<Result<CreditProductCreatedResponse>> Handle(
CreateCreditProductCommand cmd, CancellationToken ct)
{
// 1. Validar reglas de dominio
var product = CreditProduct.Create(
cmd.Code, cmd.Name, cmd.MinAmount, cmd.MaxAmount,
cmd.InterestRate, cmd.MinTermDays, cmd.MaxTermDays,
_identity.TenantId);
// 2. Persistir
await _repository.AddAsync(product, ct);
await _unitOfWork.SaveChangesAsync(ct);
// 3. Publicar evento DESPUÉS de persistir
await _publishEndpoint.Publish(new CreditProductCreated
{
EventId = Guid.NewGuid(),
EventType = "lending.product.created",
TenantId = _identity.TenantId,
Source = "imagy-lending",
ActorId = _identity.UserId,
Data = new { product.Id, product.Code, product.Name }
}, ct);
return Result<CreditProductCreatedResponse>.Success(new(product.Id));
}
}Validación (FluentValidation)
csharp
public class CreateCreditProductValidator : AbstractValidator<CreateCreditProductCommand>
{
public CreateCreditProductValidator()
{
RuleFor(x => x.Code)
.NotEmpty()
.MaximumLength(50)
.Matches(@"^[a-z0-9\-]+$").WithMessage("Solo lowercase, números y guiones");
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.MinAmount).GreaterThan(0);
RuleFor(x => x.MaxAmount).GreaterThan(x => x.MinAmount);
RuleFor(x => x.InterestRate).InclusiveBetween(0, 1);
RuleFor(x => x.MinTermDays).GreaterThan(0);
RuleFor(x => x.MaxTermDays).GreaterThan(x => x.MinTermDays);
}
}Controllers (delgados — solo mapeo)
csharp
[ApiVersion("1.0")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class CreditProductsController : ControllerBase
{
private readonly IMediator _mediator;
[HttpPost]
[Authorize(Roles = "tenant_admin,platform_admin")]
public async Task<IActionResult> Create([FromBody] CreateCreditProductDto dto)
{
var command = dto.ToCommand();
var result = await _mediator.Send(command);
return result.IsSuccess
? CreatedAtAction(nameof(GetById), new { id = result.Value.Id }, result.Value)
: BadRequest(result.ToErrorResponse());
}
[HttpGet]
[Authorize]
public async Task<IActionResult> GetAll([FromQuery] GetCreditProductsQueryParams queryParams)
{
var query = queryParams.ToQuery();
var result = await _mediator.Send(query);
return Ok(result.ToApiResponse());
}
}Result Pattern
csharp
public class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public List<ErrorDetail> Errors { get; }
public static Result<T> Success(T value) => new(true, value, []);
public static Result<T> Failure(string code, string message) =>
new(false, default, [new ErrorDetail(code, message)]);
public static Result<T> Failure(params ErrorDetail[] errors) =>
new(false, default, errors.ToList());
}
public record ErrorDetail(string Code, string Message, string? Field = null);Repository Pattern
csharp
// Interface en Application layer
public interface ICreditProductRepository
{
Task<CreditProduct?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<CreditProduct?> GetByCodeAsync(string code, CancellationToken ct = default);
Task AddAsync(CreditProduct product, CancellationToken ct = default);
}
// Implementación en Infrastructure (EF Core → Primary)
public class CreditProductRepository : ICreditProductRepository
{
private readonly LendingDbContext _db;
public async Task<CreditProduct?> GetByIdAsync(Guid id, CancellationToken ct) =>
await _db.CreditProducts.FirstOrDefaultAsync(p => p.Id == id, ct);
}
// Read Repository (Dapper → Read Replica)
public class CreditProductReadRepository : ICreditProductReadRepository
{
private readonly IDbConnection _readDb;
private readonly IIdentityContext _identity;
public async Task<PagedResult<CreditProductDto>> GetAllAsync(
bool? isActive, int page, int pageSize)
{
await SetTenantContextAsync();
var sql = @"SELECT id, code, name, min_amount, max_amount, interest_rate, is_active
FROM credit_products
WHERE (@IsActive IS NULL OR is_active = @IsActive)
ORDER BY created_at DESC
LIMIT @Limit OFFSET @Offset";
var results = await _readDb.QueryAsync<CreditProductDto>(sql, new
{
IsActive = isActive,
Limit = pageSize,
Offset = (page - 1) * pageSize
});
return new PagedResult<CreditProductDto>(results, total, page, pageSize);
}
private async Task SetTenantContextAsync()
{
await _readDb.ExecuteAsync(
"SET app.current_tenant_id = @TenantId",
new { _identity.TenantId });
}
}Naming Conventions (.NET)
| Elemento | Convención | Ejemplo |
|---|---|---|
| Namespaces | PascalCase, prefijo Imagy | Imagy.Lending.Application |
| Clases | PascalCase | CreditProduct, CreateCreditProductHandler |
| Interfaces | I + PascalCase | ICreditProductRepository |
| Métodos | PascalCase | GetByIdAsync, CalculateInterest |
| Properties | PascalCase | InterestRate, TenantId |
| Private fields | _camelCase | _repository, _identity |
| Parameters | camelCase | tenantId, creditProduct |
| Constants | PascalCase | MaxRetryAttempts |
| Enums | PascalCase (singular) | CreditStatus.Active |
| Records (DTOs) | PascalCase + sufijo | CreditProductDto, CreateCreditProductCommand |
| Async methods | Sufijo Async | GetByIdAsync, SaveChangesAsync |
Frontend (React 19 + TypeScript 6)
Estructura de Proyecto (Panel Admin)
src/
├── app/ # Configuración global
│ ├── App.tsx
│ ├── router.tsx
│ └── providers.tsx
├── components/ # Componentes compartidos
│ ├── ui/ # Primitivos (Button, Input, Table, Modal)
│ ├── form/ # FormField, FormSection
│ ├── layout/ # Shell, Sidebar, Header
│ └── feedback/ # Toast, Alert, Loading
├── features/ # Módulos por dominio
│ ├── lending/ # ImagLend
│ │ ├── products/
│ │ ├── applications/
│ │ └── portfolio/
│ ├── flows/ # ImagFlow
│ │ ├── designer/
│ │ ├── executions/
│ │ └── providers/
│ ├── sign/ # ImagSign
│ ├── subjects/ # ImagID
│ └── settings/ # Configuración del tenant
├── hooks/ # Hooks compartidos
├── stores/ # Zustand stores
├── types/ # Tipos globales
├── lib/ # Utilidades (api client, formatters)
└── config/ # Configuración de la appComponentes
typescript
// Componente funcional con tipos explícitos
interface CreditProductCardProps {
product: CreditProductDto;
onEdit: (id: string) => void;
onToggleActive: (id: string, active: boolean) => void;
}
const CreditProductCard: React.FC<CreditProductCardProps> = ({
product,
onEdit,
onToggleActive,
}) => {
return (
<div className="rounded-lg border p-4">
<h3 className="text-lg font-medium">{product.name}</h3>
<p className="text-sm text-muted">{product.code}</p>
{/* ... */}
</div>
);
};Hooks y Data Fetching (TanStack Query)
typescript
// Hook para queries
export function useCreditProducts(filters?: CreditProductFilters) {
return useQuery({
queryKey: ['credit-products', filters],
queryFn: () => api.get<PagedResponse<CreditProductDto>>('/api/v1/credit-products', {
params: filters,
}),
});
}
// Hook para mutations
export function useCreateCreditProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCreditProductDto) =>
api.post<CreditProductCreatedResponse>('/api/v1/credit-products', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credit-products'] });
toast.success('Producto creado exitosamente');
},
onError: (error) => {
toast.error(error.message);
},
});
}Formularios (React Hook Form + Zod)
typescript
const creditProductSchema = z.object({
code: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/),
name: z.string().min(1).max(200),
minAmount: z.number().positive(),
maxAmount: z.number().positive(),
interestRate: z.number().min(0).max(1),
minTermDays: z.number().int().positive(),
maxTermDays: z.number().int().positive(),
});
type CreditProductFormData = z.infer<typeof creditProductSchema>;
const CreditProductForm: React.FC<{ onSubmit: (data: CreditProductFormData) => void }> = ({
onSubmit,
}) => {
const { register, handleSubmit, formState: { errors } } = useForm<CreditProductFormData>({
resolver: zodResolver(creditProductSchema),
mode: 'onBlur',
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<FormField label="Código" error={errors.code?.message} required>
<Input {...register('code')} placeholder="microcredito-30d" />
</FormField>
{/* ... */}
</form>
);
};State Management (Zustand)
typescript
// Store por feature — no un store global gigante
interface LendingStore {
selectedProductId: string | null;
filters: CreditProductFilters;
setSelectedProduct: (id: string | null) => void;
setFilters: (filters: Partial<CreditProductFilters>) => void;
resetFilters: () => void;
}
export const useLendingStore = create<LendingStore>((set) => ({
selectedProductId: null,
filters: { page: 1, pageSize: 20 },
setSelectedProduct: (id) => set({ selectedProductId: id }),
setFilters: (filters) => set((state) => ({
filters: { ...state.filters, ...filters },
})),
resetFilters: () => set({ filters: { page: 1, pageSize: 20 } }),
}));Naming Conventions (Frontend)
| Elemento | Convención | Ejemplo |
|---|---|---|
| Componentes | PascalCase | CreditProductCard.tsx |
| Hooks | camelCase con use | useCreditProducts.ts |
| Stores | camelCase con use + Store | useLendingStore.ts |
| Types/Interfaces | PascalCase | CreditProductDto |
| Schemas (Zod) | camelCase + Schema | creditProductSchema |
| Utilidades | camelCase | formatCurrency.ts |
| Constantes | UPPER_SNAKE_CASE | MAX_FILE_SIZE |
| CSS classes | kebab-case (Tailwind) | text-sm font-medium |
| Archivos de componente | PascalCase | CreditProductForm.tsx |
| Archivos de hook/util | camelCase | useCreditProducts.ts |
| Carpetas | kebab-case | credit-products/, data-model/ |
API Client
typescript
// lib/api.ts — instancia Axios configurada
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true, // envía cookie de sesión
});
// Interceptor: refresh automático si 401
api.interceptors.response.use(
(response) => response.data,
async (error) => {
if (error.response?.status === 401) {
// Redirigir a login
window.location.href = `/api/v1/auth/login?cid=${getTenantCode()}`;
}
return Promise.reject(error.response?.data?.error ?? error);
}
);Reglas Generales (Backend + Frontend)
Patrones Obligatorios
| Patrón | Dónde | Por qué |
|---|---|---|
| Result pattern | Backend Application layer | No excepciones para flujo de negocio |
| CQRS (MediatR) | Backend Application layer | Separación commands/queries |
| Repository pattern | Backend Infrastructure | Abstracción de acceso a datos |
| Dependency Injection | Backend (todo) | Testabilidad |
| Guard clauses | Backend Domain | Validar invariantes |
| Hooks + TanStack Query | Frontend | Data fetching declarativo |
| Zod schemas | Frontend | Validación type-safe |
| Feature-sliced | Frontend | Organización por dominio |
Patrones Prohibidos
| Anti-patrón | Por qué |
|---|---|
| Service Locator | Oculta dependencias |
| God class / God component | Dividir responsabilidades |
| Anemic domain model | Entidades con comportamiento |
| Magic strings/numbers | Usar constantes o enums |
any en TypeScript | Siempre tipar explícitamente |
catch (Exception) sin logging | Siempre loggear |
Task.Result / .Wait() | Siempre await |
| Lógica en controllers | Solo mapeo y delegación |
useEffect para data fetching | Usar TanStack Query |
| Props drilling (>3 niveles) | Usar Zustand o Context |
Seguridad en Código
- Input validation en AMBOS lados (frontend con Zod, backend con FluentValidation)
- Queries parametrizadas (nunca string interpolation en SQL)
- No loggear datos sensibles (tokens, passwords, PII)
- Pinned versions en dependencias (no rangos abiertos)
- Headers de seguridad en todas las respuestas
- No exponer stack traces al cliente
- HTTPS obligatorio en producción
.editorconfig (obligatorio en cada repo)
ini
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.cs]
dotnet_sort_system_directives_first = true
csharp_new_line_before_open_brace = all
csharp_prefer_braces = true
csharp_style_var_for_built_in_types = false
csharp_style_var_when_type_is_apparent = true
csharp_style_var_elsewhere = false
[*.{ts,tsx}]
indent_size = 2
[*.{json,yml,yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = falseCode Review Checklist
Antes de aprobar un PR:
- [ ] Input validation presente (frontend Y backend)
- [ ] Queries parametrizadas (no string interpolation)
- [ ] No se loggean datos sensibles
- [ ] Errores manejados correctamente
- [ ] Tests cubren happy path + al menos 1 error path
- [ ] No hay TODOs sin issue asociado
- [ ] Eventos publicados DESPUÉS de persistir
- [ ] Tenant context establecido correctamente
- [ ] No hay dependencias nuevas sin justificación
- [ ] Tipos explícitos (no
anyen TS, nodynamicen C#) - [ ] Código legible sin comentarios explicativos