Skip to content

Support Domain Model Object Graphs with a 3-Tier Remote Data Mapper and Authentication

License

Notifications You must be signed in to change notification settings

NeatooDotNet/RemoteFactory

Repository files navigation

Neatoo RemoteFactory

Roslyn Source Generator-powered Data Mapper Factory for 3-tier .NET applications

RemoteFactory eliminates DTOs, manual factories, and API controllers by generating everything at compile time. Write domain model methods once, get client and server implementations automatically.

The Opportunity

With Blazor WebAssembly, the same .NET library can run on both client and server. This changes everything.

The old world (JavaScript SPA + ASP.NET Core): Your domain model lived on the server. To get data to the browser, you serialized to JSON, wrote DTOs to receive it, and mapped back and forth. Two representations of the same thing.

The new world (Blazor WASM + ASP.NET Core): Your domain model can run in the browser. The same Employee class executes client-side and server-side. No translation layer needed.

RemoteFactory makes this practical. It generates the factories, serialization, and HTTP plumbing so your domain methods "just work" across the wire. One source of truth, zero boilerplate.

Why RemoteFactory?

Traditional 3-tier architecture:

  • Write domain model methods
  • Create DTOs to transfer state
  • Build factories to map between DTOs and domain models
  • Write API controllers to expose operations
  • Maintain all four layers as requirements change

With RemoteFactory:

  • Write domain model methods
  • Add attributes ([Factory], [Remote], [Create], [Fetch], [Insert], [Update], [Delete], etc.)
  • Done. Generator creates factories, serialization, and endpoints.

Quick Example

Domain model with factory methods:

public interface IPerson : IFactorySaveMeta
{
    Guid Id { get; }
    string FirstName { get; set; }
    string LastName { get; set; }
    string? Email { get; set; }
    new bool IsDeleted { get; set; }
}

[Factory]
[SuppressFactory] // Suppress factory generation for documentation sample
public partial class Person : IPerson
{
    public Guid Id { get; private set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string? Email { get; set; }
    public bool IsNew { get; private set; } = true;
    public bool IsDeleted { get; set; }

    [Create]
    public Person()
    {
        Id = Guid.NewGuid();
    }

    [Remote]
    [Fetch]
    public async Task<bool> Fetch(Guid id, [Service] IPersonRepository repository)
    {
        var entity = await repository.GetByIdAsync(id);
        if (entity == null) return false;

        Id = entity.Id;
        FirstName = entity.FirstName;
        LastName = entity.LastName;
        Email = entity.Email;
        IsNew = false;
        return true;
    }

    [Remote]
    [Insert]
    public async Task Insert([Service] IPersonRepository repository)
    {
        var entity = new PersonEntity
        {
            Id = Id,
            FirstName = FirstName,
            LastName = LastName,
            Email = Email,
            Created = DateTime.UtcNow,
            Modified = DateTime.UtcNow
        };
        await repository.AddAsync(entity);
        await repository.SaveChangesAsync();
        IsNew = false;
    }

    [Remote]
    [Update]
    public async Task Update([Service] IPersonRepository repository)
    {
        var entity = await repository.GetByIdAsync(Id)
            ?? throw new InvalidOperationException($"Person {Id} not found");

        entity.FirstName = FirstName;
        entity.LastName = LastName;
        entity.Email = Email;
        entity.Modified = DateTime.UtcNow;

        await repository.UpdateAsync(entity);
        await repository.SaveChangesAsync();
    }

    [Remote]
    [Delete]
    public async Task Delete([Service] IPersonRepository repository)
    {
        await repository.DeleteAsync(Id);
        await repository.SaveChangesAsync();
    }
}

snippet source | anchor

public interface IPerson : IFactorySaveMeta
{
    Guid Id { get; }
    string FirstName { get; set; }
    string LastName { get; set; }
    string? Email { get; set; }
    new bool IsDeleted { get; set; }
}

[Factory]
public partial class Person : IPerson
{
    public Guid Id { get; private set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string? Email { get; set; }
    public bool IsNew { get; private set; } = true;
    public bool IsDeleted { get; set; }

    [Create]
    public Person()
    {
        Id = Guid.NewGuid();
    }

    [Remote]
    [Fetch]
    public async Task<bool> Fetch(Guid id, [Service] IPersonRepository repository)
    {
        var entity = await repository.GetByIdAsync(id);
        if (entity == null) return false;

        Id = entity.Id;
        FirstName = entity.FirstName;
        LastName = entity.LastName;
        Email = entity.Email;
        IsNew = false;
        return true;
    }

    [Remote]
    [Insert]
    public async Task Insert([Service] IPersonRepository repository)
    {
        var entity = new PersonEntity
        {
            Id = Id,
            FirstName = FirstName,
            LastName = LastName,
            Email = Email,
            Created = DateTime.UtcNow,
            Modified = DateTime.UtcNow
        };
        await repository.AddAsync(entity);
        await repository.SaveChangesAsync();
        IsNew = false;
    }

    [Remote]
    [Update]
    public async Task Update([Service] IPersonRepository repository)
    {
        var entity = await repository.GetByIdAsync(Id)
            ?? throw new InvalidOperationException($"Person {Id} not found");

        entity.FirstName = FirstName;
        entity.LastName = LastName;
        entity.Email = Email;
        entity.Modified = DateTime.UtcNow;

        await repository.UpdateAsync(entity);
        await repository.SaveChangesAsync();
    }

    [Remote]
    [Delete]
    public async Task Delete([Service] IPersonRepository repository)
    {
        await repository.DeleteAsync(Id);
        await repository.SaveChangesAsync();
    }
}

snippet source | anchor

Client code calls the factory:

public static class ClientUsageExample
{
    // IPersonFactory is auto-generated from Person class
    public static async Task BasicOperations(IPersonFactory factory)
    {
        // Create a new person
        var person = factory.Create();
        person.FirstName = "John";
        person.LastName = "Doe";
        person.Email = "john.doe@example.com";

        // Save (routes to Insert because IsNew = true)
        await factory.Save(person);

        // Fetch existing
        var existing = await factory.Fetch(person.Id);

        // Update
        existing!.Email = "john.updated@example.com";
        await factory.Save(existing);  // Routes to Update

        // Delete
        existing.IsDeleted = true;
        await factory.Save(existing);  // Routes to Delete
    }
}

snippet source | anchor

// Create a new person
// var person = factory.Create();
// person.FirstName = "John";
// person.LastName = "Doe";
// person.Email = "john.doe@example.com";
//
// // Save routes to Insert (IsNew = true)
// var saved = await factory.Save(person);
//
// // Fetch an existing person
// var fetched = await factory.Fetch(saved!.Id);
// // fetched.FirstName is "John"
//
// // Update - Save routes to Update (IsNew = false)
// fetched!.Email = "john.updated@example.com";
// await factory.Save(fetched);
//
// // Delete - set IsDeleted, then Save
// fetched.IsDeleted = true;
// await factory.Save(fetched);

snippet source | anchor

Server automatically exposes the endpoint at /api/neatoo. No controllers needed.

Key Features

  • Zero boilerplate: No DTOs, no manual mapping, no controllers
  • Type-safe: Roslyn generates strongly-typed factories from your domain methods
  • DI integration: Inject services into factory methods with [Service] attribute
  • Authorization: Built-in support for custom auth or ASP.NET Core policies
  • Compact serialization: Ordinal format reduces payloads by 40-50%
  • Lifecycle hooks: IFactoryOnStart, IFactoryOnComplete, IFactoryOnCancelled
  • Fire-and-forget events: Domain events with scope isolation via [Event] attribute
  • Flexible modes: Full (server), RemoteOnly (client), or Logical (single-tier)

Installation

Install NuGet packages:

Server project:

dotnet add package Neatoo.RemoteFactory
dotnet add package Neatoo.RemoteFactory.AspNetCore

Client project (Blazor WASM, etc.):

dotnet add package Neatoo.RemoteFactory

Shared project (domain models):

dotnet add package Neatoo.RemoteFactory

Configure client assembly for smaller output:

// In client assembly's AssemblyAttributes.cs:
// [assembly: FactoryMode(FactoryModeOption.RemoteOnly)]

snippet source | anchor

// In client assembly's AssemblyAttributes.cs:
// [assembly: FactoryMode(FactoryMode.RemoteOnly)]

snippet source | anchor

Getting Started

Server setup (ASP.NET Core):

// Server setup (Program.cs):
// services.AddNeatooAspNetCore(typeof(Person).Assembly);
// app.UseNeatoo();

snippet source | anchor

public static class ServerSetup
{
    public static void ConfigureServices(IServiceCollection services)
    {
        // Register Neatoo ASP.NET Core services
        // services.AddNeatooAspNetCore(typeof(Person).Assembly);

        // Register domain services
        // services.AddScoped<IPersonRepository, PersonRepository>();
    }
}

snippet source | anchor

Client setup (Blazor WASM):

// Client setup (Program.cs):
// services.AddNeatooRemoteFactory(
//     NeatooFactory.Remote,
//     new NeatooSerializationOptions { Format = SerializationFormat.Ordinal },
//     typeof(Person).Assembly);
// services.AddKeyedScoped(RemoteFactoryServices.HttpClientKey, (sp, key) =>
//     new HttpClient { BaseAddress = new Uri("https://api.example.com/") });

snippet source | anchor

public static class ClientSetup
{
    public static void ConfigureServices(IServiceCollection services, Uri baseAddress)
    {
        // Register Neatoo RemoteFactory for client
        // services.AddNeatooRemoteFactory(
        //     NeatooFactory.Remote,
        //     typeof(Person).Assembly);

        // Register keyed HttpClient for Neatoo
        // services.AddKeyedScoped(
        //     RemoteFactoryServices.HttpClientKey,
        //     (sp, key) => new HttpClient { BaseAddress = baseAddress });
    }
}

snippet source | anchor

Domain model:

[Factory]
[SuppressFactory] // Suppress factory generation for documentation sample
[AuthorizeFactory<IPersonAuthorization>]
public partial class PersonWithAuth : IFactorySaveMeta
{
    public Guid Id { get; private set; }
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public bool IsNew { get; private set; } = true;
    public bool IsDeleted { get; set; }

    [Create]
    public PersonWithAuth() { Id = Guid.NewGuid(); }

    [Remote, Fetch]
    public async Task<bool> Fetch(
        Guid id,
        [Service] IPersonRepository repository)
    {
        var entity = await repository.GetByIdAsync(id);
        if (entity == null) return false;
        Id = entity.Id;
        FirstName = entity.FirstName;
        LastName = entity.LastName;
        IsNew = false;
        return true;
    }

    [Remote, Insert]
    public async Task Insert([Service] IPersonRepository repository)
    {
        await repository.AddAsync(new PersonEntity
        {
            Id = Id, FirstName = FirstName, LastName = LastName,
            Email = "", Created = DateTime.UtcNow, Modified = DateTime.UtcNow
        });
        await repository.SaveChangesAsync();
        IsNew = false;
    }

    [Remote, Update]
    public async Task Update([Service] IPersonRepository repository)
    {
        var entity = await repository.GetByIdAsync(Id)
            ?? throw new InvalidOperationException();
        entity.FirstName = FirstName;
        entity.LastName = LastName;
        entity.Modified = DateTime.UtcNow;
        await repository.UpdateAsync(entity);
        await repository.SaveChangesAsync();
    }

    [Remote, Delete]
    public async Task Delete([Service] IPersonRepository repository)
    {
        await repository.DeleteAsync(Id);
        await repository.SaveChangesAsync();
    }
}

public interface IPersonAuthorization
{
    [AuthorizeFactory(AuthorizeFactoryOperation.Read)]
    bool CanRead();

    [AuthorizeFactory(AuthorizeFactoryOperation.Write)]
    bool CanWrite();
}

snippet source | anchor

[Factory]
[AuthorizeFactory<IPersonAuthorization>]
public partial class PersonWithAuth : IFactorySaveMeta
{
    public Guid Id { get; private set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string? Email { get; set; }
    public bool IsNew { get; private set; } = true;
    public bool IsDeleted { get; set; }

    [Create]
    public PersonWithAuth()
    {
        Id = Guid.NewGuid();
    }

    [Remote]
    [Fetch]
    public async Task<bool> Fetch(Guid id, [Service] IPersonRepository repository)
    {
        var entity = await repository.GetByIdAsync(id);
        if (entity == null) return false;

        Id = entity.Id;
        FirstName = entity.FirstName;
        LastName = entity.LastName;
        Email = entity.Email;
        IsNew = false;
        return true;
    }

    [Remote]
    [Insert]
    public async Task Insert([Service] IPersonRepository repository)
    {
        var entity = new PersonEntity
        {
            Id = Id,
            FirstName = FirstName,
            LastName = LastName,
            Email = Email,
            Created = DateTime.UtcNow,
            Modified = DateTime.UtcNow
        };
        await repository.AddAsync(entity);
        await repository.SaveChangesAsync();
        IsNew = false;
    }

    [Remote]
    [Update]
    public async Task Update([Service] IPersonRepository repository)
    {
        var entity = await repository.GetByIdAsync(Id)
            ?? throw new InvalidOperationException($"Person {Id} not found");

        entity.FirstName = FirstName;
        entity.LastName = LastName;
        entity.Email = Email;
        entity.Modified = DateTime.UtcNow;

        await repository.UpdateAsync(entity);
        await repository.SaveChangesAsync();
    }

    [Remote]
    [Delete]
    public async Task Delete([Service] IPersonRepository repository)
    {
        await repository.DeleteAsync(Id);
        await repository.SaveChangesAsync();
    }
}

public interface IPersonAuthorization
{
    [AuthorizeFactory(AuthorizeFactoryOperation.Create)]
    bool CanCreate();

    [AuthorizeFactory(AuthorizeFactoryOperation.Read)]
    bool CanRead();

    [AuthorizeFactory(AuthorizeFactoryOperation.Write)]
    bool CanWrite();
}

public class PersonAuthorization : IPersonAuthorization
{
    private readonly IUserContext _userContext;

    public PersonAuthorization(IUserContext userContext)
    {
        _userContext = userContext;
    }

    public bool CanCreate() => _userContext.IsAuthenticated;
    public bool CanRead() => _userContext.IsAuthenticated;
    public bool CanWrite() =>
        _userContext.IsInRole("Admin") || _userContext.IsInRole("Manager");
}

snippet source | anchor

See Getting Started for a complete walkthrough.

Documentation

Supported Frameworks

  • .NET 8.0 (LTS)
  • .NET 9.0 (STS)
  • .NET 10.0 (LTS)

Examples

Complete working examples in src/Examples/:

  • Person - Simple Blazor WASM CRUD application
  • OrderEntry - Order entry system with aggregate roots

License

MIT License - see LICENSE for details.

Links

About

Support Domain Model Object Graphs with a 3-Tier Remote Data Mapper and Authentication

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages