> ## 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.

# Adding a Feature End to End

> Complete walkthrough adding a Projects feature — domain entity to React page.

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

<Steps>
  <Step title="Create the domain entity">
    ```csharp theme={null}
    // 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 }
    ```
  </Step>

  <Step title="Create the EF Core configuration">
    ```csharp theme={null}
    // 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);
        }
    }
    ```
  </Step>

  <Step title="Add DbSet to ApplicationDbContext">
    ```csharp theme={null}
    // 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`:

    ```csharp theme={null}
    modelBuilder.ApplyConfiguration(new ProjectConfiguration(_tenantContext));
    ```
  </Step>

  <Step title="Add the interface to IApplicationDbContext">
    ```csharp theme={null}
    // src/Acme.Application/Common/Interfaces/IApplicationDbContext.cs
    // Add this line:
    DbSet<Project> Projects { get; }
    ```
  </Step>

  <Step title="Create the migration">
    ```bash theme={null}
    make shell-api
    dotnet ef migrations add AddProjectsTable \
      --project src/Acme.Infrastructure \
      --startup-project src/Acme.Api
    exit
    make migrate
    ```
  </Step>

  <Step title="Create the DTO">
    ```csharp theme={null}
    // src/Acme.Application/Projects/DTOs/ProjectDto.cs
    public record ProjectDto(
        Guid Id,
        string Name,
        string Description,
        string Status,
        DateTime CreatedAt,
        DateTime? UpdatedAt
    );
    ```
  </Step>

  <Step title="Create CreateProjectCommand">
    ```csharp theme={null}
    // 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));
        }
    }
    ```
  </Step>

  <Step title="Create GetProjectsQuery">
    ```csharp theme={null}
    // 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
            ));
        }
    }
    ```
  </Step>

  <Step title="Create ProjectsController">
    ```csharp theme={null}
    // 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);
    ```
  </Step>

  <Step title="Create the TypeScript types">
    ```typescript theme={null}
    // 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;
    }
    ```
  </Step>

  <Step title="Create TanStack Query hooks">
    ```typescript theme={null}
    // 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 });
        },
      });
    }
    ```
  </Step>

  <Step title="Create the ProjectsPage component">
    ```tsx theme={null}
    // 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>
      );
    }
    ```
  </Step>

  <Step title="Add the route">
    ```typescript theme={null}
    // frontend/src/router/index.tsx — add inside the authenticated route group:
    {
      path: 'projects',
      element: <ProjectsPage />,
    }
    ```
  </Step>

  <Step title="Add to the sidebar">
    ```tsx theme={null}
    // frontend/src/components/layout/Sidebar.tsx — add to the navigation items array:
    {
      label: 'Projects',
      href: '/projects',
      icon: FolderOpen,  // from lucide-react
    }
    ```
  </Step>

  <Step title="Add to the command palette">
    ```typescript theme={null}
    // 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'),
    }
    ```
  </Step>
</Steps>

That is the complete path from domain entity to rendered React page. The pattern repeats for every feature in AspFox.
