Skip to content

refactor: Migrate from AutoMapper to Mapperly#2093

Draft
niemyjski wants to merge 2 commits intomainfrom
feature/mapperly
Draft

refactor: Migrate from AutoMapper to Mapperly#2093
niemyjski wants to merge 2 commits intomainfrom
feature/mapperly

Conversation

@niemyjski
Copy link
Member

@niemyjski niemyjski commented Jan 30, 2026

Summary

Migrates the API mapping layer from AutoMapper (runtime reflection) to Mapperly (compile-time source generation) for improved performance, type safety, and debuggability.

Changes

Core Migration

  • Removed AutoMapper dependency and all Profile-based mapping configurations
  • Added Mapperly 4.3.1 as an analyzer/source generator
  • Created type-specific mappers: OrganizationMapper, ProjectMapper, TokenMapper, UserMapper, WebHookMapper, InvoiceMapper
  • Created ApiMapper facade registered as singleton in DI, injected into all controllers
  • Added abstract MapToViewModel, MapToViewModels, MapToModel methods on base controllers

Mapping Structure

Mapper Source → Target
OrganizationMapper NewOrganizationOrganization, OrganizationViewOrganization
ProjectMapper NewProjectProject, ProjectViewProject
TokenMapper NewTokenToken, TokenViewToken
UserMapper UserViewUser
WebHookMapper NewWebHookWebHook
InvoiceMapper Stripe.InvoiceInvoiceGridModel (manual, not Mapperly)

Safety Improvements

  • Added [MapperIgnoreTarget] annotations on computed/populated-later properties (e.g., IsOverMonthlyLimit, IsThrottled, ProjectCount, StackCount, EventCount, HasSlackIntegration, HasPremiumFeatures, OrganizationName)
  • Upgraded UserMapper to RequiredMappingStrategy.Target — any new ViewUser property will produce a compile warning unless explicitly mapped or ignored
  • Let Mapperly generate native collection methods (MapToViewTokens, MapToViewUsers) instead of manual .Select().ToList()

Bug Fixes

  • Restored StackUsageKey record in StackService — ValueTuples serialize to {} with System.Text.Json (no IncludeFields)

Tests

  • Added 29 mapper unit tests across 6 test files
  • Added SuspensionCode enum→string mapping tests
  • All 31 mapper unit tests pass, build has 0 warnings

Design Notes

  • RequiredMappingStrategy.None for mixed mappers (NewModel→Model + Model→ViewModel) to avoid verbose ignores on the creation side where most domain properties are intentionally unset
  • RequiredMappingStrategy.Target for UserMapper (ViewModel-only direction) for compile-time safety
  • Mapperly does shallow reference copies for same-type collections/objects (same behavior as AutoMapper)

Breaking Changes

Controllers inheriting from RepositoryApiController or ReadOnlyRepositoryApiController must implement the abstract mapping methods (MapToViewModel, MapToViewModels, MapToModel).

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the previous AutoMapper-based runtime mapping with Mapperly-based (and some manual) mappings, refactors controller base classes to use explicit mapping hooks, and wires up an ApiMapper facade plus dedicated per-type mappers, along with unit tests to validate the new mappings.

Changes:

  • Removed AutoMapper dependencies and registration from Exceptionless.Core and Exceptionless.Web, adding Riok.Mapperly and an ApiMapper facade with per-type mappers (OrganizationMapper, ProjectMapper, TokenMapper, UserMapper, WebHookMapper, InvoiceMapper).
  • Refactored ReadOnlyRepositoryApiController and RepositoryApiController to use abstract MapToModel / MapToViewModel / MapToViewModels methods implemented by concrete controllers instead of generic MapAsync/MapCollectionAsync.
  • Updated controllers (OrganizationController, ProjectController, TokenController, UserController, WebHookController, EventController, StackController) to depend on ApiMapper and implement the new mapping hooks, and added focused mapper unit tests under tests/Exceptionless.Tests/Mapping.

Reviewed changes

Copilot reviewed 27 out of 27 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/Exceptionless.Core/Exceptionless.Core.csproj Removes the AutoMapper package reference, completing the core-layer dependency removal.
src/Exceptionless.Core/Models/CoreMappings.cs Deletes the empty CoreMappings AutoMapper profile, as mappings are no longer configured in core.
src/Exceptionless.Core/Bootstrapper.cs Removes AutoMapper profile registration and IMapper singleton wiring, ensuring DI no longer constructs an AutoMapper instance.
src/Exceptionless.Web/Exceptionless.Web.csproj Adds a reference to Riok.Mapperly so Mapperly source generation can run for the new mapping classes.
src/Exceptionless.Web/Bootstrapper.cs Drops the AutoMapper ApiMappings profile and IMapper registration, and registers ApiMapper as a singleton for controller injection.
src/Exceptionless.Web/Mapping/ApiMapper.cs Introduces an ApiMapper facade that composes OrganizationMapper, ProjectMapper, TokenMapper, UserMapper, WebHookMapper, and InvoiceMapper and exposes strongly-typed mapping methods used by controllers.
src/Exceptionless.Web/Mapping/OrganizationMapper.cs Adds a Mapperly-based mapper from NewOrganizationOrganization and OrganizationViewOrganization, with explicit post-processing of IsOverMonthlyLimit using Organization.IsOverMonthlyLimit(TimeProvider), plus list mapping helpers.
src/Exceptionless.Web/Mapping/ProjectMapper.cs Adds a Mapperly-based mapper from NewProjectProject and a two-stage ProjectViewProject mapping (core Mapperly map plus explicit HasSlackIntegration computation), plus list mapping helpers.
src/Exceptionless.Web/Mapping/TokenMapper.cs Adds a Mapperly-based mapper from NewTokenToken that ignores Token.Type (leaving its default) and maps TokenViewToken, plus list mapping helpers; preserves prior AutoMapper semantics around token type.
src/Exceptionless.Web/Mapping/UserMapper.cs Adds a Mapperly-based mapper from UserViewUser with list mapping helpers, replacing the AutoMapper user mapping.
src/Exceptionless.Web/Mapping/WebHookMapper.cs Adds a Mapperly-based mapper from NewWebHookWebHook, relying on domain defaults and WebHookController.AddModelAsync to normalize Version as before.
src/Exceptionless.Web/Mapping/InvoiceMapper.cs Introduces a manual mapper from Stripe.InvoiceInvoiceGridModel that strips the "in_" prefix from Id and copies Created and Paid, plus list mapping helpers, matching the old AutoMapper AfterMap behavior.
src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs Replaces generic AutoMapper-based MapAsync/MapCollectionAsync with abstract MapToViewModel / MapToViewModels, injects ApiMapper instead of IMapper, and ensures AfterResultMapAsync still strips sensitive data from IData.Data.
src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs Updates to depend on ApiMapper, adds an abstract MapToModel(TNewModel) hook, and refactors PostImplAsync / UpdateModelAsync / UpdateModelsAsync to use the new mapping hooks plus AfterResultMapAsync rather than the removed AutoMapper helpers.
src/Exceptionless.Web/Controllers/OrganizationController.cs Switches constructor from IMapper to ApiMapper, implements organization-specific MapToModel/MapToViewModel/MapToViewModels using OrganizationMapper, replaces generic mapping calls with these methods (and explicit AfterResultMapAsync calls), and maps Stripe invoices via ApiMapper.MapToInvoiceGridModels.
src/Exceptionless.Web/Controllers/ProjectController.cs Switches to ApiMapper, implements project-specific mapping methods using ProjectMapper, replaces MapAsync/MapCollectionAsync usage with these methods, and ensures AfterResultMapAsync still enriches ViewProject instances with organization data and usage stats.
src/Exceptionless.Web/Controllers/TokenController.cs Switches to ApiMapper, adds token-specific mapping overrides using TokenMapper, and replaces generic collection mapping with MapToViewModels plus AfterResultMapAsync for organization- and project-based token listing endpoints.
src/Exceptionless.Web/Controllers/UserController.cs Removes AutoMapper usage, injects ApiMapper, implements mapping overrides (using ViewUser as both new and view model and mapping UserViewUser via UserMapper), and updates organization-based user listing to use the new mapping flow plus AfterResultMapAsync.
src/Exceptionless.Web/Controllers/WebHookController.cs Replaces IMapper with ApiMapper, implements webhook mapping overrides using WebHookMapper and identity mapping for the WebHook view model, while leaving webhook permission and version-normalization logic intact.
src/Exceptionless.Web/Controllers/StackController.cs Switches injection to ApiMapper and implements mapping overrides that treat Stack as both model and view model (identity mapping), ensuring the new base class’ requirements are satisfied without changing behavior.
src/Exceptionless.Web/Controllers/EventController.cs Switches from IMapper to ApiMapper, implements identity mapping overrides for PersistentEvent, and replaces the AutoMapper-based UserDescriptionEventUserDescription mapping in SetUserDescriptionAsync with an explicit object initializer that sets all relevant properties.
tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs Adds unit tests that validate OrganizationMapper correctly maps NewOrganizationOrganization and OrganizationViewOrganization, including suspension state and list mappings.
tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs Adds unit tests checking ProjectMapper mappings, including Slack integration detection and collection mappings.
tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs Adds unit tests for TokenMapper, ensuring organization/project/notes are mapped, Token.Type is left at its default, and collection mappings work.
tests/Exceptionless.Tests/Mapping/UserMapperTests.cs Adds unit tests verifying UserMapper correctly maps core user properties, roles, organization IDs, and collections.
tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs Adds unit tests for WebHookMapper, verifying that organization/project IDs, URL, and event types are correctly transferred, including null and empty edge cases for project/event types.
tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs Adds unit tests ensuring InvoiceMapper strips the "in_" prefix from Stripe invoice IDs, maps Created and Paid, and supports collection mapping.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- Replace AutoMapper (runtime reflection) with Mapperly (compile-time source generation)
- Add Riok.Mapperly 4.2.1 to Exceptionless.Web
- Remove AutoMapper 14.0.0 from Exceptionless.Core

Breaking changes:
- Controllers now use abstract mapping methods instead of generic MapAsync<T>
- Base controllers require derived classes to implement MapToModel, MapToViewModel, MapToViewModels

Mapping structure:
- Created dedicated mapper files per type in src/Exceptionless.Web/Mapping/
  - OrganizationMapper: NewOrganization -> Organization, Organization -> ViewOrganization
  - ProjectMapper: NewProject -> Project, Project -> ViewProject
  - TokenMapper: NewToken -> Token, Token -> ViewToken
  - UserMapper: User -> ViewUser
  - WebHookMapper: NewWebHook -> WebHook
  - InvoiceMapper: Stripe.Invoice -> InvoiceGridModel
- ApiMapper facade delegates to individual mappers

Testing:
- Added comprehensive unit tests for all mappers (29 tests)
- Tests follow backend-testing skill patterns

Benefits:
- Compile-time type safety for mappings
- Better performance (no runtime reflection)
- Cleaner separation of concerns with per-type mappers
- Add [MapperIgnoreTarget] for computed/populated-later properties on
  OrganizationMapper (IsOverRequestLimit, IsThrottled, ProjectCount,
  StackCount, EventCount) and ProjectMapper (HasPremiumFeatures,
  OrganizationName, StackCount, EventCount)
- Upgrade UserMapper to RequiredMappingStrategy.Target since it only
  maps Model→ViewModel; new ViewUser properties will produce compile
  warnings unless explicitly mapped or ignored
- Add [MapperIgnoreTarget] for ViewUser.IsInvite (manually constructed)
- Let Mapperly generate collection methods (MapToViewTokens,
  MapToViewUsers) natively instead of manual .Select().ToList()
- Add SuspensionCode enum→string mapping tests
- Fix StackService ValueTuple serialization bug (restore StackUsageKey)
- Update Mapperly 4.2.1 → 4.3.1

Co-authored-by: Copilot <[email protected]>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/Exceptionless.Core/Services/StackService.cs:66

  • SaveStackUsagesAsync will now deserialize the existing Redis list entries under usage:occurrences as StackUsageKey. Any previously stored ValueTuple entries were serialized by STJ as {}; those will deserialize into a StackUsageKey with null/empty ids, which then flows into cache key generation and _stackRepository.IncrementEventCounterAsync (and the retry path calls IncrementStackUsageAsync, which will throw). This can break the background usage save loop after deployment.

Consider versioning GetStackOccurrenceSetCacheKey() (e.g. usage:occurrences:v2) and/or adding a compatibility path that detects invalid items (missing ids) and clears/removes the old list without retrying.

    public async Task SaveStackUsagesAsync(bool sendNotifications = true, CancellationToken cancellationToken = default)
    {
        string occurrenceSetCacheKey = GetStackOccurrenceSetCacheKey();
        var stackUsageSet = await _cache.GetListAsync<StackUsageKey>(occurrenceSetCacheKey);
        if (!stackUsageSet.HasValue)
            return;

        foreach (var usage in stackUsageSet.Value)
        {
            if (cancellationToken.IsCancellationRequested)
                break;

            string organizationId = usage.OrganizationId;
            string projectId = usage.ProjectId;
            string stackId = usage.StackId;

            var removeFromSetTask = _cache.ListRemoveAsync(occurrenceSetCacheKey, new StackUsageKey(organizationId, projectId, stackId));
            string countCacheKey = GetStackOccurrenceCountCacheKey(stackId);
            var countTask = _cache.GetAsync<long>(countCacheKey, 0);
            string minDateCacheKey = GetStackOccurrenceMinDateCacheKey(stackId);
            var minDateTask = _cache.GetUnixTimeMillisecondsAsync(minDateCacheKey, _timeProvider.GetUtcNow().UtcDateTime);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -16,6 +16,7 @@
<PackageReference Include="Exceptionless.AspNetCore" Version="6.1.0" />
<PackageReference Include="Joonasw.AspNetCore.SecurityHeaders" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says Riok.Mapperly 4.2.1 was added, but the csproj references 4.3.1. Please align the PR description or the package version so they match (this helps with release notes and dependency tracking).

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed — the PR description now correctly says 4.3.1, and the csproj references 4.3.1. The first commit originally added 4.2.1, but the second commit updated it to 4.3.1 (latest stable).

@github-actions
Copy link

Code Coverage

Package Line Rate Branch Rate Complexity Health
Exceptionless.Insulation 24% 23% 208
Exceptionless.Core 67% 60% 7520
Exceptionless.AppHost 26% 14% 55
Exceptionless.Web 57% 42% 3505
Summary 62% (12248 / 19869) 53% (5756 / 10782) 11288

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

2 participants