This guide adds aDocumentation Index
Fetch the complete documentation index at: https://docs.aspfox.com/llms.txt
Use this file to discover all available pages before exploring further.
Projects feature from scratch, following the same patterns used throughout AspFox. Every code block here is compilable — not pseudocode.
Create the domain entity
// src/Acme.Domain/Entities/Project.cs
public class Project : BaseEntity, ITenantScopedEntity, IAuditableEntity
{
public string Name { get; private set; } = string.Empty;
public string Description { get; private set; } = string.Empty;
public ProjectStatus Status { get; private set; } = ProjectStatus.Active;
public Guid TenantId { get; private set; }
public Tenant Tenant { get; private set; } = null!;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
private Project() { }
public static Project Create(string name, string description, Guid tenantId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return new Project
{
Id = Guid.NewGuid(),
Name = name,
Description = description,
TenantId = tenantId,
Status = ProjectStatus.Active
};
}
public void Update(string name, string description)
{
Name = name;
Description = description;
}
}
public enum ProjectStatus { Active, Archived }
Create the EF Core configuration
// src/Acme.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs
public class ProjectConfiguration : IEntityTypeConfiguration<Project>
{
private readonly TenantContext _tenantContext;
public ProjectConfiguration(TenantContext tenantContext)
{
_tenantContext = tenantContext;
}
public void Configure(EntityTypeBuilder<Project> builder)
{
builder.ToTable("projects");
builder.HasKey(p => p.Id);
builder.Property(p => p.Name)
.HasMaxLength(200)
.IsRequired();
builder.Property(p => p.Description)
.HasMaxLength(2000);
builder.Property(p => p.Status)
.HasConversion<string>()
.HasMaxLength(20);
builder.HasIndex(p => p.TenantId);
builder.HasIndex(p => new { p.TenantId, p.Name }).IsUnique();
builder.HasOne(p => p.Tenant)
.WithMany()
.HasForeignKey(p => p.TenantId)
.OnDelete(DeleteBehavior.Cascade);
// Critical: tenant isolation
builder.HasQueryFilter(p => p.TenantId == _tenantContext.CurrentTenantId);
}
}
Add DbSet to ApplicationDbContext
// src/Acme.Infrastructure/Persistence/ApplicationDbContext.cs
// Add this line with the other DbSets:
public DbSet<Project> Projects => Set<Project>();
OnModelCreating:modelBuilder.ApplyConfiguration(new ProjectConfiguration(_tenantContext));
Add the interface to IApplicationDbContext
// src/Acme.Application/Common/Interfaces/IApplicationDbContext.cs
// Add this line:
DbSet<Project> Projects { get; }
Create the migration
make shell-api
dotnet ef migrations add AddProjectsTable \
--project src/Acme.Infrastructure \
--startup-project src/Acme.Api
exit
make migrate
Create the DTO
// src/Acme.Application/Projects/DTOs/ProjectDto.cs
public record ProjectDto(
Guid Id,
string Name,
string Description,
string Status,
DateTime CreatedAt,
DateTime? UpdatedAt
);
Create CreateProjectCommand
// src/Acme.Application/Projects/Commands/CreateProject/CreateProjectCommand.cs
public record CreateProjectCommand(
string Name,
string Description,
Guid TenantId
) : IRequest<Result<ProjectDto>>;
// Validator
public class CreateProjectCommandValidator : AbstractValidator<CreateProjectCommand>
{
public CreateProjectCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(200)
.WithMessage("Project name must be between 1 and 200 characters.");
RuleFor(x => x.Description)
.MaximumLength(2000);
}
}
// Handler
public class CreateProjectCommandHandler
: IRequestHandler<CreateProjectCommand, Result<ProjectDto>>
{
private readonly IApplicationDbContext _context;
private readonly INotificationService _notificationService;
private readonly IMapper _mapper;
public CreateProjectCommandHandler(
IApplicationDbContext context,
INotificationService notificationService,
IMapper mapper)
{
_context = context;
_notificationService = notificationService;
_mapper = mapper;
}
public async Task<Result<ProjectDto>> Handle(
CreateProjectCommand request,
CancellationToken cancellationToken)
{
var duplicate = await _context.Projects
.AnyAsync(p => p.Name == request.Name, cancellationToken);
if (duplicate)
return Result.Failure<ProjectDto>(
new Error("CONFLICT", $"A project named '{request.Name}' already exists."));
var project = Project.Create(request.Name, request.Description, request.TenantId);
_context.Projects.Add(project);
await _context.SaveChangesAsync(cancellationToken);
await _notificationService.CreateForAllTenantMembersAsync(
tenantId: request.TenantId,
type: NotificationType.ProjectCreated,
title: "New project created",
message: $"Project \"{project.Name}\" was created.",
actionUrl: $"/projects/{project.Id}"
);
return Result.Success(_mapper.Map<ProjectDto>(project));
}
}
Create GetProjectsQuery
// src/Acme.Application/Projects/Queries/GetProjects/GetProjectsQuery.cs
public record GetProjectsQuery(
string? Search,
int Page = 1,
int PageSize = 20
) : IRequest<Result<PagedResult<ProjectDto>>>;
public class GetProjectsQueryHandler
: IRequestHandler<GetProjectsQuery, Result<PagedResult<ProjectDto>>>
{
private readonly IApplicationDbContext _context;
private readonly IMapper _mapper;
public GetProjectsQueryHandler(IApplicationDbContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public async Task<Result<PagedResult<ProjectDto>>> Handle(
GetProjectsQuery request,
CancellationToken cancellationToken)
{
var query = _context.Projects.AsQueryable();
if (!string.IsNullOrWhiteSpace(request.Search))
query = query.Where(p => p.Name.Contains(request.Search));
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(p => p.CreatedAt)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken);
return Result.Success(new PagedResult<ProjectDto>(
Items: _mapper.Map<List<ProjectDto>>(items),
Total: total,
Page: request.Page,
PageSize: request.PageSize
));
}
}
Create ProjectsController
// src/Acme.Api/Controllers/ProjectsController.cs
[ApiController]
[Route("api/v1/[controller]")]
[Authorize]
public class ProjectsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ICurrentUserService _currentUser;
public ProjectsController(IMediator mediator, ICurrentUserService currentUser)
{
_mediator = mediator;
_currentUser = currentUser;
}
[HttpGet]
[HasPermission(Permissions.TenantSettingsRead)]
public async Task<IActionResult> GetProjects(
[FromQuery] string? search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await _mediator.Send(
new GetProjectsQuery(search, page, pageSize));
return result.ToActionResult();
}
[HttpPost]
[HasPermission(Permissions.TenantSettingsEdit)]
public async Task<IActionResult> CreateProject([FromBody] CreateProjectRequest request)
{
var command = new CreateProjectCommand(
request.Name,
request.Description,
_currentUser.TenantId!.Value
);
var result = await _mediator.Send(command);
return result.ToActionResult(StatusCodes.Status201Created);
}
}
public record CreateProjectRequest(string Name, string Description);
Create the TypeScript types
// frontend/src/features/projects/types.ts
export interface Project {
id: string;
name: string;
description: string;
status: 'Active' | 'Archived';
createdAt: string;
updatedAt: string | null;
}
export interface CreateProjectRequest {
name: string;
description: string;
}
export interface ProjectsResponse {
items: Project[];
total: number;
page: number;
pageSize: number;
}
Create TanStack Query hooks
// frontend/src/features/projects/hooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/axios';
import type { Project, CreateProjectRequest, ProjectsResponse } from './types';
export const projectKeys = {
all: ['projects'] as const,
list: (search?: string) => [...projectKeys.all, 'list', search] as const,
};
export function useProjects(search?: string) {
return useQuery({
queryKey: projectKeys.list(search),
queryFn: async (): Promise<ProjectsResponse> => {
const params = search ? { search } : {};
const { data } = await api.get('/api/v1/projects', { params });
return data.data;
},
});
}
export function useCreateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (request: CreateProjectRequest): Promise<Project> => {
const { data } = await api.post('/api/v1/projects', request);
return data.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectKeys.all });
},
});
}
Create the ProjectsPage component
// frontend/src/features/projects/ProjectsPage.tsx
import { useState } from 'react';
import { useProjects, useCreateProject } from './hooks';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
export function ProjectsPage() {
const [search, setSearch] = useState('');
const { data, isLoading, isError, refetch } = useProjects(search || undefined);
const createProject = useCreateProject();
if (isLoading) {
return (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
);
}
if (isError) {
return (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<p className="text-muted-foreground">Failed to load projects.</p>
<Button variant="outline" onClick={() => refetch()}>
Try again
</Button>
</div>
);
}
if (!data || data.items.length === 0) {
return (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<p className="text-muted-foreground">No projects yet.</p>
<Button onClick={() => createProject.mutate({ name: 'My First Project', description: '' })}>
Create your first project
</Button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Input
placeholder="Search projects…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs"
/>
<Button onClick={() => createProject.mutate({ name: 'New Project', description: '' })}>
New project
</Button>
</div>
<ul className="space-y-2">
{data.items.map((project) => (
<li
key={project.id}
className="rounded-lg border border-border bg-background p-4"
>
<p className="font-medium text-foreground">{project.name}</p>
{project.description && (
<p className="mt-1 text-sm text-muted-foreground">{project.description}</p>
)}
</li>
))}
</ul>
</div>
);
}
Add the route
// frontend/src/router/index.tsx — add inside the authenticated route group:
{
path: 'projects',
element: <ProjectsPage />,
}
Add to the sidebar
// frontend/src/components/layout/Sidebar.tsx — add to the navigation items array:
{
label: 'Projects',
href: '/projects',
icon: FolderOpen, // from lucide-react
}