Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.aspfox.com/llms.txt

Use this file to discover all available pages before exploring further.

This guide adds a Projects feature from scratch, following the same patterns used throughout AspFox. Every code block here is compilable — not pseudocode.
1

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 }
2

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);
    }
}
3

Add DbSet to ApplicationDbContext

// src/Acme.Infrastructure/Persistence/ApplicationDbContext.cs
// Add this line with the other DbSets:
public DbSet<Project> Projects => Set<Project>();
Also register the configuration in OnModelCreating:
modelBuilder.ApplyConfiguration(new ProjectConfiguration(_tenantContext));
4

Add the interface to IApplicationDbContext

// src/Acme.Application/Common/Interfaces/IApplicationDbContext.cs
// Add this line:
DbSet<Project> Projects { get; }
5

Create the migration

make shell-api
dotnet ef migrations add AddProjectsTable \
  --project src/Acme.Infrastructure \
  --startup-project src/Acme.Api
exit
make migrate
6

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
);
7

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));
    }
}
8

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
        ));
    }
}
9

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);
10

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;
}
11

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 });
    },
  });
}
12

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>
  );
}
13

Add the route

// frontend/src/router/index.tsx — add inside the authenticated route group:
{
  path: 'projects',
  element: <ProjectsPage />,
}
14

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
}
15

Add to the command palette

// frontend/src/components/command-palette/CommandPalette.tsx
// Add to the navigation commands array:
{
  id: 'nav-projects',
  label: 'Projects',
  group: 'Navigation',
  icon: FolderOpen,
  action: () => navigate('/projects'),
}
That is the complete path from domain entity to rendered React page. The pattern repeats for every feature in AspFox.