Factory Operations Reference
This reference documents all factory-related attributes and the generated factory interface. Factories are generated by Roslyn Source Generators based on your entity definitions, providing type-safe lifecycle management for your domain objects.
Factory Attributes
[Factory]
Marks a class for factory generation. Required on any class that needs a generated factory.
[Factory]
internal partial class Person : EntityBase<Person>, IPerson
{
// ...
}
The source generator creates:
IPersonFactoryinterfacePersonFactoryimplementation- Custom serializers for remote operations
- DI registrations
Naming Convention:
| Entity Class | Interface | Generated Factory |
|---|---|---|
Person |
IPerson |
IPersonFactory |
Order |
IOrder |
IOrderFactory |
PersonPhoneList |
IPersonPhoneList |
IPersonPhoneListFactory |
[Create]
Marks a method to be called when creating a new entity instance.
[Create]
public void Create([Service] IChildListFactory childFactory)
{
// Initialize new entity
ChildList = childFactory.Create();
Status = EntityStatus.Draft;
CreatedDate = DateTime.UtcNow;
}
Characteristics:
- Called when
factory.Create()is invoked - Entity has
IsNew == trueafter creation - Typically not marked
[Remote](runs locally) - Used to initialize child collections and default values
Return Type:
void- Standard creationTask- Async creation (if async initialization needed)
Generated Factory Method:
// From [Create] public void Create([Service] IChildFactory f)
IPerson? Create();
// From [Create] public void Create(string name, [Service] IChildFactory f)
IPerson? Create(string name);
[Create] with Records
C# records (10.1.0+) support a type-level [Create] attribute, enabling concise Value Object declarations:
[Factory]
[Create]
public record Money(decimal Amount, string Currency = "USD");
Generated Factory:
public interface IMoneyFactory
{
Money Create(decimal amount, string currency = "USD");
}
Records with Service Injection:
[Factory]
[Create]
public record Address(
string Street,
string City,
string State,
string ZipCode,
[Service] IAddressValidator validator);
// Generated (services hidden from interface):
public interface IAddressFactory
{
Address Create(string street, string city, string state, string zipCode);
}
Records with Validation:
For validation logic, use a static [Create] method:
[Factory]
public record Money(decimal Amount, string Currency)
{
[Create]
public static Money Create(decimal amount, string currency = "USD")
{
if (amount < 0)
throw new ArgumentException("Amount cannot be negative");
return new Money(amount, currency.ToUpperInvariant());
}
}
Record Type Constraints:
| Type | Supported |
|---|---|
record |
✅ Yes |
record class |
✅ Yes |
record struct |
❌ No (diagnostic NF0206) |
See C# Records for Value Objects for comprehensive coverage.
[Fetch]
Marks a method to retrieve an existing entity from persistence.
[Remote]
[Fetch]
public async Task<bool> Fetch(
[Service] IPersonDbContext dbContext,
[Service] IPersonPhoneListFactory phoneListFactory)
{
var entity = await dbContext.Persons
.Include(p => p.Phones)
.FirstOrDefaultAsync(p => p.Id == Id);
if (entity == null) return false;
MapFrom(entity);
PersonPhoneList = await phoneListFactory.Fetch(entity.Phones);
return true;
}
Characteristics:
- Called when
factory.Fetch()is invoked - Entity has
IsNew == falseafter fetch - Usually marked
[Remote](needs database access) - Return
falseornullif entity not found
Return Type:
bool- Returnfalsefor “not found”Task<bool>- Async fetch with not-found handling- Entity type - Return null if not found
void/Task- Throws if not found
Generated Factory Method:
// From [Fetch] public async Task<bool> Fetch([Service] IDbContext db)
Task<IPerson?> Fetch();
// From [Fetch] public async Task<bool> Fetch(Guid id, [Service] IDbContext db)
Task<IPerson?> Fetch(Guid id);
[Insert]
Marks a method to persist a new entity.
[Remote]
[Insert]
public async Task Insert(
[Service] IPersonDbContext dbContext,
[Service] IPersonPhoneListFactory phoneListFactory)
{
Id = Guid.NewGuid();
var entity = new PersonEntity();
MapTo(entity); // Copy ALL properties
dbContext.Persons.Add(entity);
await phoneListFactory.Save(PersonPhoneList, Id.Value);
await dbContext.SaveChangesAsync();
}
Characteristics:
- Called by
factory.Save()whenIsNew == true - Entity has
IsNew == falseafter successful insert - Almost always marked
[Remote](needs database) - Generate identity (GUID) here or let database generate
Return Type:
void/Task- Standard insert- Entity type - Return updated persistence entity for reference
[Update]
Marks a method to persist changes to an existing entity.
[Remote]
[Update]
public async Task Update(
[Service] IPersonDbContext dbContext,
[Service] IPersonPhoneListFactory phoneListFactory)
{
var entity = await dbContext.Persons.FindAsync(Id);
MapModifiedTo(entity); // Copy only MODIFIED properties
await phoneListFactory.Save(PersonPhoneList, Id.Value);
await dbContext.SaveChangesAsync();
}
Characteristics:
- Called by
factory.Save()whenIsNew == falseandIsDeleted == false - Entity has
IsModified == falseafter successful update - Almost always marked
[Remote](needs database) - Use
MapModifiedTo()for efficient updates
Return Type:
void/Task- Standard update- Entity type - Return updated persistence entity for reference
[Delete]
Marks a method to remove an entity from persistence.
[Remote]
[Delete]
public async Task Delete([Service] IPersonDbContext dbContext)
{
var entity = await dbContext.Persons.FindAsync(Id);
if (entity != null)
{
dbContext.Persons.Remove(entity);
await dbContext.SaveChangesAsync();
}
}
Characteristics:
- Called by
factory.Save()whenIsDeleted == true - Trigger by calling
entity.Delete()before save - Almost always marked
[Remote](needs database) - Handle cascading deletes to children
To Delete an Entity:
person.Delete(); // Sets IsDeleted = true
await factory.Save(person); // Calls [Delete] method
[Remote]
Indicates that the operation must execute on the server.
[Remote]
[Fetch]
public async Task<bool> Fetch([Service] IDbContext db)
{
// This code runs on the server, even when called from Blazor WASM
}
Behavior by Client Type:
| Client | Without [Remote] | With [Remote] |
|---|---|---|
| Blazor WASM | Runs in browser | Serializes to server |
| Blazor Server | Runs on server | Runs on server |
| Console/WPF | Runs locally | Serializes to server |
When to Use:
- Database access required
- Server-only services needed
- Authoritative operations (validation must be trusted)
- Resources not available on client
When NOT to Use:
[Create]operations that only initialize state- Operations using only client-available data
- Performance-critical operations that can run locally
Method Parameters
Factory methods support two types of parameters: regular parameters passed by the caller and service parameters injected from DI.
Regular Parameters
Regular parameters are passed through to the generated factory interface:
[Fetch]
public async Task<bool> Fetch(
Guid personId, // Regular - appears in IPersonFactory.Fetch()
bool includePhones, // Regular - appears in IPersonFactory.Fetch()
[Service] IDbContext db) // Service - hidden from factory interface
{
// personId and includePhones come from caller
// db is injected at runtime
}
Generated Interface:
Task<IPerson?> Fetch(Guid personId, bool includePhones);
[Service] Parameters
Parameters marked with [Service] are resolved from the dependency injection container:
[Remote]
[Insert]
public async Task Insert(
[Service] IPersonDbContext dbContext,
[Service] IPersonPhoneListFactory phoneListFactory,
[Service] IEmailService emailService)
{
// All services injected at runtime
}
Service Resolution:
- Resolved from
IServiceProviderwhen the method is called - Scoped to the request (disposed after method completes)
- Must be registered in your DI container
Parameter Order Rule:
Regular parameters must come before [Service] parameters:
// Correct
[Fetch]
public Task<bool> Fetch(
Guid id, // Regular first
string filter, // Regular second
[Service] IDbContext db) // Services last
{ }
// Incorrect - will cause compilation error
[Fetch]
public Task<bool> Fetch(
[Service] IDbContext db, // Services cannot come first
Guid id)
{ }
Passing Parameters to Factory Methods
The generated factory exposes regular parameters:
// Entity method
[Fetch]
public async Task<bool> Fetch(
Guid id,
bool includeChildren,
[Service] IDbContext db)
{ }
// Generated factory interface
public interface IPersonFactory
{
Task<IPerson?> Fetch(Guid id, bool includeChildren);
}
// Usage
var person = await personFactory.Fetch(personId, includeChildren: true);
Generated Factory Interface
For each entity with [Factory], the source generator creates a factory interface.
Standard Factory Interface
public interface IPersonFactory
{
// Lifecycle operations
IPerson? Create();
Task<IPerson?> Fetch(Guid id);
Task<IPerson?> Save(IPerson target);
Task<Authorized<IPerson>> TrySave(IPerson target);
// Authorization checks
Authorized CanCreate();
Authorized CanFetch();
Authorized CanInsert();
Authorized CanUpdate();
Authorized CanDelete();
Authorized CanSave();
}
Create()
Instantiates a new entity by calling the [Create] method:
var person = personFactory.Create();
// person.IsNew == true
// person.IsModified == true
Parameters: Match regular parameters from your [Create] method.
Returns: The entity interface type (IPerson?).
Fetch()
Retrieves an entity by calling the [Fetch] method:
var person = await personFactory.Fetch(personId);
if (person == null)
{
// Entity not found
}
// person.IsNew == false
// person.IsModified == false
Parameters: Match regular parameters from your [Fetch] method.
Returns: The entity interface type, or null if not found.
Save()
Persists changes by routing to [Insert], [Update], or [Delete]:
// New entity -> Insert
var newPerson = personFactory.Create();
newPerson.FirstName = "John";
var savedPerson = await personFactory.Save(newPerson);
// savedPerson.IsNew == false
// Existing entity -> Update
var existingPerson = await personFactory.Fetch(id);
existingPerson.LastName = "Updated";
await personFactory.Save(existingPerson);
// Deleted entity -> Delete
existingPerson.Delete();
await personFactory.Save(existingPerson);
Routing Logic:
| Entity State | Operation Called |
|---|---|
IsDeleted == true |
[Delete] method |
IsNew == true |
[Insert] method |
IsNew == false |
[Update] method |
Parameters: Always takes the entity as the first parameter. Additional parameters come from your [Insert]/[Update]/[Delete] methods.
Returns: The updated entity.
Throws: SaveOperationException if entity is not savable (see IsSavable).
Critical: Always Reassign After Save()
When you call Save(), the aggregate is serialized to the server, persisted, and a new instance is returned via deserialization. You MUST capture this return value:
// CORRECT - captures the new deserialized instance
person = await personFactory.Save(person);
// WRONG - original object is now stale!
await personFactory.Save(person);
// person still has old state, no database-generated values
Why This Happens
The Remote Factory pattern transfers your object across the client-server boundary:
- Client: Your aggregate is serialized to JSON/binary
- Server: A new instance is created from that data, persistence runs
- Server: The updated aggregate is serialized back
- Client: A NEW instance is deserialized and returned
The object you started with is not the same object that comes back. They are two different instances in memory.
Consequences of Forgetting
| What You Lose | Example |
|---|---|
| Database-generated IDs | person.Id remains Guid.Empty or 0 |
| Server-computed values | Timestamps, calculated fields |
| Updated validation state | IsValid, IsSavable reflect old state |
| Property modification flags | IsModified doesn’t reflect saved state |
| Concurrency tokens | RowVersion/ETag for optimistic concurrency |
See also: Blazor Integration - Reassign After Save for Blazor-specific guidance.
TrySave()
Like Save(), but returns authorization result instead of throwing:
var result = await personFactory.TrySave(person);
if (result.IsAuthorized)
{
var savedPerson = result.Value;
// Success
}
else
{
var message = result.Message;
// Show authorization failure to user
}
Returns: Authorized<IPerson> containing either the saved entity or an authorization failure message.
CanXYZ() Methods
Check authorization before attempting operations:
// Check before showing UI elements
if (personFactory.CanCreate().IsAuthorized)
{
// Show "Create New" button
}
if (personFactory.CanDelete().IsAuthorized)
{
// Show "Delete" button
}
// Check with message
var canUpdate = personFactory.CanUpdate();
if (!canUpdate.IsAuthorized)
{
ShowMessage(canUpdate.Message); // "You don't have permission..."
}
Available Methods:
| Method | Checks Authorization For |
|---|---|
CanCreate() |
Creating new entities |
CanFetch() |
Retrieving entities |
CanInsert() |
Inserting new entities |
CanUpdate() |
Updating existing entities |
CanDelete() |
Deleting entities |
CanSave() |
Current save operation (routes based on state) |
Save() Validation Requirements
The Save() method verifies the entity is savable before proceeding:
public virtual bool IsSavable => IsModified && IsValid && !IsBusy && !IsChild;
Requirements:
| Property | Required Value | Reason |
|---|---|---|
IsModified |
true |
Nothing to save if unchanged |
IsValid |
true |
Cannot persist invalid data |
IsBusy |
false |
Async rules must complete first |
IsChild |
false |
Children save through their parent |
Checking Before Save:
if (person.IsSavable)
{
await personFactory.Save(person);
}
else
{
if (!person.IsValid)
ShowValidationErrors(person.PropertyMessages);
else if (person.IsBusy)
ShowMessage("Please wait for validation to complete");
else if (!person.IsModified)
ShowMessage("No changes to save");
else if (person.IsChild)
ShowMessage("Save the parent entity instead");
}
Collection Factory Methods
Entity list factories have slightly different patterns.
Collection Create
Creates an empty collection:
[Factory]
internal partial class PersonPhoneList : EntityListBase<IPersonPhone>, IPersonPhoneList
{
[Create]
public void Create()
{
// Usually empty - collection is initialized by base class
}
}
Usage:
var phoneList = phoneListFactory.Create();
// Returns empty collection ready for items
Collection Fetch
Populates the collection from persistence data:
[Fetch]
public async Task Fetch(
IEnumerable<PersonPhoneEntity> entities,
[Service] IPersonPhoneFactory phoneFactory)
{
foreach (var entity in entities)
{
var phone = await phoneFactory.Fetch(entity);
Add(phone);
}
}
The parent entity passes the data:
// In Person's Fetch method
PersonPhoneList = await phoneListFactory.Fetch(entity.Phones);
Collection Save (Update)
Handles inserts, updates, and deletes within the collection:
[Remote]
[Update]
public async Task Update(
Guid parentId,
[Service] IPersonDbContext dbContext)
{
// Process deletions from DeletedList
foreach (var deleted in DeletedList.Cast<IPersonPhone>())
{
var entity = await dbContext.PersonPhones.FindAsync(deleted.Id);
if (entity != null)
{
dbContext.PersonPhones.Remove(entity);
}
}
// Process remaining items
foreach (var phone in this)
{
if (phone.IsNew)
{
// Insert
phone.Id = Guid.NewGuid();
var entity = new PersonPhoneEntity { PersonId = parentId };
phone.MapTo(entity);
dbContext.PersonPhones.Add(entity);
}
else if (phone.IsModified)
{
// Update
var entity = await dbContext.PersonPhones.FindAsync(phone.Id);
phone.MapModifiedTo(entity);
}
}
await dbContext.SaveChangesAsync();
}
The parent calls the collection’s save:
// In Person's Update method
await phoneListFactory.Save(PersonPhoneList, Id.Value);
Complete Examples
Basic Entity Factory
[Factory]
internal partial class Person : EntityBase<Person>, IPerson
{
public Person(IEntityBaseServices<Person> services) : base(services) { }
public partial Guid? Id { get; set; }
public partial string? Name { get; set; }
// MapFrom and MapTo are manually implemented
// Only MapModifiedTo is source-generated
public void MapFrom(PersonEntity entity)
{
LoadProperty(nameof(Id), entity.Id);
LoadProperty(nameof(Name), entity.Name);
}
public void MapTo(PersonEntity entity)
{
entity.Id = Id ?? Guid.Empty;
entity.Name = Name;
}
public partial void MapModifiedTo(PersonEntity entity);
[Create]
public void Create()
{
// Initialize defaults
}
[Remote]
[Fetch]
public async Task<bool> Fetch([Service] IDbContext db)
{
var entity = await db.Persons.FindAsync(Id);
if (entity == null) return false;
MapFrom(entity);
return true;
}
[Remote]
[Insert]
public async Task Insert([Service] IDbContext db)
{
Id = Guid.NewGuid();
var entity = new PersonEntity();
MapTo(entity);
db.Persons.Add(entity);
await db.SaveChangesAsync();
}
[Remote]
[Update]
public async Task Update([Service] IDbContext db)
{
var entity = await db.Persons.FindAsync(Id);
MapModifiedTo(entity);
await db.SaveChangesAsync();
}
[Remote]
[Delete]
public async Task Delete([Service] IDbContext db)
{
var entity = await db.Persons.FindAsync(Id);
if (entity != null)
{
db.Persons.Remove(entity);
await db.SaveChangesAsync();
}
}
}
Entity with Multiple Fetch Overloads
[Factory]
internal partial class Order : EntityBase<Order>, IOrder
{
// Fetch by ID
[Remote]
[Fetch]
public async Task<bool> Fetch([Service] IDbContext db)
{
var entity = await db.Orders.FindAsync(Id);
if (entity == null) return false;
MapFrom(entity);
return true;
}
// Fetch by order number
[Remote]
[Fetch]
public async Task<bool> FetchByOrderNumber(
string orderNumber,
[Service] IDbContext db)
{
var entity = await db.Orders
.FirstOrDefaultAsync(o => o.OrderNumber == orderNumber);
if (entity == null) return false;
MapFrom(entity);
return true;
}
}
// Generated factory includes both:
public interface IOrderFactory
{
Task<IOrder?> Fetch(); // Uses Id property
Task<IOrder?> FetchByOrderNumber(string orderNumber); // Uses parameter
}
Entity with Parent-Child Relationship
[Factory]
internal partial class Order : EntityBase<Order>, IOrder
{
public partial IOrderLineList? Lines { get; set; }
[Create]
public void Create([Service] IOrderLineListFactory lineFactory)
{
Lines = lineFactory.Create();
}
[Remote]
[Fetch]
public async Task<bool> Fetch(
[Service] IDbContext db,
[Service] IOrderLineListFactory lineFactory)
{
var entity = await db.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == Id);
if (entity == null) return false;
MapFrom(entity);
Lines = await lineFactory.Fetch(entity.Lines);
return true;
}
[Remote]
[Insert]
public async Task Insert(
[Service] IDbContext db,
[Service] IOrderLineListFactory lineFactory)
{
Id = Guid.NewGuid();
var entity = new OrderEntity();
MapTo(entity);
db.Orders.Add(entity);
// Save children
await lineFactory.Save(Lines, Id.Value);
await db.SaveChangesAsync();
}
[Remote]
[Update]
public async Task Update(
[Service] IDbContext db,
[Service] IOrderLineListFactory lineFactory)
{
var entity = await db.Orders.FindAsync(Id);
MapModifiedTo(entity);
// Save children (handles inserts, updates, deletes)
await lineFactory.Save(Lines, Id.Value);
await db.SaveChangesAsync();
}
[Remote]
[Delete]
public async Task Delete([Service] IDbContext db)
{
// Cascade delete is handled by database or explicitly here
var entity = await db.Orders.FindAsync(Id);
if (entity != null)
{
db.Orders.Remove(entity);
await db.SaveChangesAsync();
}
}
}
Related Topics
- Factory Pattern Concept - Understanding the factory pattern
- Data Mapping Reference - MapFrom, MapTo, MapModifiedTo
- Client-Server Architecture - How [Remote] works
- EntityBase Reference - Entity lifecycle and meta-properties
- EntityListBase Reference - Collection factory patterns