This document provides detailed information about the data models, services, and API patterns used in Mes Recettes.
- Data Models
- Validation Rules
- Services
- Supabase Integration
- API Patterns
- Error Handling
- Service Reuse & Patterns
[Table("recettes")]
public class Recipe : BaseModel
{
[PrimaryKey("id")]
public int Id { get; set; }
[Column("name")]
[Required(ErrorMessage = "The Name field is required.")]
public string Name { get; set; } = string.Empty;
[Column("rating")]
[Range(0, 5, ErrorMessage = "Rating must be between 0 and 5")]
public int Rating { get; set; }
[Column("created_at")]
public DateTime CreationDate { get; set; }
[Column("notes")]
public string? Notes { get; set; }
[Column("book_id")]
public int? BookId { get; set; }
[Column("page")]
public int? BookPage { get; set; }
[Column("store_id")]
public int? StoreId { get; set; }
[Column("url")]
[Url(ErrorMessage = "Please enter a valid URL")]
public string? Url { get; set; }
**Validation Rules:**
- `Name`: Required (cannot be empty)
- `Rating`: Must be between 0 and 5 (0 = not rated)
- `BookPage`: Optional, must be positive if provided
- `Url`: Optional, must be a valid URL format if provided
- `BookId`: Optional foreign key (reference to Book)
- `StoreId`: Optional foreign key (reference to Store)
### Book Model
```csharp
[Table("books")]
public class Book : BaseModel
{
[PrimaryKey("id")]
public int Id { get; set; }
[Column("title")]
public string Name { get; set; } = string.Empty;
[Column("created_at")]
public DateTime CreationDate { get; set; }
[Reference(typeof(Author), includeInQuery: true, useInnerJoin: false)]
public List<Author> Authors { get; set; } = new();
}Validation Rules:
Title: Required (stored astitlecolumn)Authors: Many-to-many viabooks_authors
[Table("stores")]
public class Store : BaseModel
{
[PrimaryKey("id")]
public int Id { get; set; }
[Column("name")]
[Required(ErrorMessage = "The store name is required.")]
[MaxLength(255, ErrorMessage = "Store name cannot exceed 255 characters")]
public string Name { get; set; } = string.Empty;
[Column("address")]
[MaxLength(500, ErrorMessage = "Address cannot exceed 500 characters")]
public string? Address { get; set; }
[Column("phone")]
[MaxLength(50, ErrorMessage = "Phone number cannot exceed 50 characters")]
public string? Phone { get; set; }
[Column("website")]
[MaxLength(500, ErrorMessage = "Website URL cannot exceed 500 characters")]
[Url(ErrorMessage = "Please enter a valid URL")]
public string? Website { get; set; }
[Column("notes")]
public string? Notes { get; set; }
[Column("created_at")]
public DateTime CreationDate { get; set; }
}Validation Rules:
Name: Required, max 255 charsAddress: Optional, max 500 charsPhone: Optional, max 50 charsWebsite: Optional, max 500 chars, must be valid URLNotes: Optional
[Table("authors")]
public class Author : BaseModel
{
[PrimaryKey("id")]
public int Id { get; set; }
[Column("first_name")]
public string Name { get; set; } = string.Empty;
[Column("last_name")]
public string? LastName { get; set; }
[Column("created_at")]
public DateTime CreationDate { get; set; }
[Reference(typeof(Book), useInnerJoin: false, includeInQuery: true)]
public List<Book> Books { get; set; } = [];
[JsonIgnore]
public string FullName => string.IsNullOrWhiteSpace(LastName)
? Name
: $"{Name} {LastName}".Trim();
}Validation Rules:
FirstName: RequiredLastName: Optional
erDiagram
Author ||--o{ BookAuthor : "has"
Book ||--o{ BookAuthor : "has"
BookAuthor }o--|| Author : "references"
BookAuthor }o--|| Book : "references"
Book ||--o{ Recipe : "contains"
Store ||--o{ Recipe : "sells"
Author {
int id PK
string first_name
string last_name
datetime created_at
}
Book {
int id PK
string title
datetime created_at
}
BookAuthor {
int book_id FK
int author_id FK
datetime created_at
}
Store {
int id PK
string name
string address
string phone
string website
string notes
datetime created_at
}
Recipe {
int id PK
string name
text notes
int rating "1-5 stars, 0 = not rated"
int book_id FK
int store_id FK
int page
string url "optional website URL"
datetime created_at
}
The application uses comprehensive validation through System.ComponentModel.DataAnnotations and custom business rules.
| Property | Validation Rule | Error Message |
|---|---|---|
Name |
Required | "The Name field is required." |
Rating |
Range(0, 5), 0 = not rated | "Rating must be between 0 and 5" |
PageNumber |
Optional, validated positive when provided | "Book page number must be positive" |
Url |
Url format | "Please enter a valid URL" |
StoreId/BookId |
Optional foreign keys | N/A |
| Property | Validation Rule | Error Message |
|---|---|---|
FirstName |
Required | "The Name field is required." |
LastName |
Optional | N/A |
| Property | Validation Rule | Error Message |
|---|---|---|
Title |
Required | "Book title is required" |
Authors |
Optional collection | N/A |
| Property | Validation Rule | Error Message |
|---|---|---|
Name |
Required, MaxLength(255) | "The store name is required." / "Store name cannot exceed 255 characters" |
Address |
MaxLength(500) | "Address cannot exceed 500 characters" |
Phone |
MaxLength(50) | "Phone number cannot exceed 50 characters" |
Website |
MaxLength(500), Url | "Please enter a valid URL" / "Website URL cannot exceed 500 characters" |
Notes |
Optional | N/A |
Our comprehensive test suite includes 559 tests covering all validation scenarios, services, components, and integration testing (as reported by dotnet test):
// Example: Rating validation test
[Theory]
[InlineData(0, true)] // Valid: not rated
[InlineData(1, true)] // Valid: minimum rating
[InlineData(3, true)] // Valid: middle
[InlineData(5, true)] // Valid: maximum
[InlineData(6, false)] // Invalid: above range
[InlineData(-1, false)] // Invalid: negative
public void Rating_ShouldValidateRange_ForAllValues(int rating, bool isValid)
{
// Validation testing implementation
}The service layer follows a query/service pattern and now relies on shared helpers to reduce duplication and standardize behavior:
CrudServiceBase<TModel, TService>centralizes common CRUD flows with consistent logging, error mapping, andCancellationTokenpropagation down to the Supabase calls (cancellation is rethrown, never converted to a failureResult)ValidationGuardsprovides reusable validation helpers used by all servicesCacheServiceExtensionsaddsGetOrEmptyAsyncfor resilient list caching andRemoveManyfor cache invalidation
Currently, AuthorService, BookService, and StoreService derive from the CRUD base and use these helpers; RecipeService uses caching helpers for related cache invalidation.
public interface IRecipeService
{
Task<Result<(IReadOnlyList<Recipe> Items, int Total)>> SearchAsync(...);
Task<Result<Recipe>> GetByIdAsync(int id, CancellationToken ct = default);
Task<Result<Recipe>> CreateAsync(Recipe recipe, CancellationToken ct = default);
Task<Result<Recipe>> UpdateAsync(Recipe recipe, CancellationToken ct = default);
Task<Result<bool>> DeleteAsync(int id, CancellationToken ct = default);
// Lightweight summaries (id, book_id, store_id, rating, created_at only)
// used by Stores counts and Dashboard statistics instead of loading full recipes
Task<Result<IReadOnlyList<Recipe>>> GetRecipeSummariesAsync(int? rating = null, CancellationToken ct = default);
Task<Result<IReadOnlyList<Recipe>>> GetRecipesByIdsAsync(IReadOnlyCollection<int> ids, CancellationToken ct = default);
Task<IReadOnlyList<Book>> GetBooksAsync(CancellationToken ct = default);
Task<IReadOnlyList<Author>> GetAuthorsAsync(CancellationToken ct = default);
Task<IReadOnlyList<Store>> GetStoresAsync(CancellationToken ct = default);
}These services implement their respective interfaces and derive from the CRUD base to:
- Validate inputs using
ValidationGuards - Use Supabase for data persistence
- Invalidate relevant caches on mutations
- Keep domain-specific logic (e.g., book-author associations)
IBookAuthorService exposes LoadAuthorsForBooksAsync(IReadOnlyCollection<Book>), which loads the
authors of many books in two queries (associations + authors) instead of one pair of queries per
book; BookService.GetAllAsync uses it to avoid N+1 query patterns.
AuthService wraps Supabase GoTrue via ISupabaseAuthWrapper:
SignInAsyncreturns aSignInOutcomeenum (Success,InvalidCredentials,EmailNotConfirmed,NetworkError,TooManyRequests,UnknownError) so the sign-in dialog can show targeted messagesSendPasswordResetAsync(email)triggers Supabase's password reset email ("Mot de passe oublié")AuthStateChangedfires on any auth state change;SessionExpiredfires when the session ends without a user-initiated sign-out (expired/rejected refresh token) and is surfaced as a warning snackbar inMainLayout- Write operations rejected by RLS (Postgrest 401/403) return a distinct re-login message instead of the generic unexpected-error text
public class SupabaseConfig
{
public string Url { get; set; } = string.Empty;
public string Key { get; set; } = string.Empty;
}
// In Program.cs
var supabaseConfig = builder.Configuration.GetSection("Supabase").Get<SupabaseConfig>();
builder.Services.AddScoped(_ => new SupabaseClient(supabaseConfig.Url, supabaseConfig.Key));// Get all records
var recipes = await supabaseClient.From<Recipe>().Get();
// Get by ID
var recipe = await supabaseClient.From<Recipe>().Where(x => x.Id == id).Single();
// Update
var updated = await supabaseClient.From<Recipe>().Update(recipe);
// Delete
await supabaseClient.From<Recipe>().Where(x => x.Id == id).Delete();The application uses a Result<T> pattern for service results, combined with structured logging and consistent error messages.
Common exception handling patterns are centralized via the base class helpers where possible. Network errors surface a standard message; unexpected errors map to a user-friendly message with logged details.
For more information, see: