mcpField
A
abp-service-patterns

abp-service-patterns

github.com/thapaliyabikendra開発2457 回インストール31 週間前に更新
リポジトリ

abp-service-patterns について

ABP Framework 開発者向けに、アプリケーション層の設計パターンをまとめたスキルです。サービス設計、マッピング、Unit of Work、フィルター、ログ、マッピング検証を扱います。

主な機能

  1. 01アプリケーションサービスのパターン
  2. 02Mapperly によるオブジェクトマッピング
  3. 03Unit of Work の制御
  4. 04Filter DTO によるクエリ
  5. 05ログと入力のサニタイズ
  6. 06手動マッピングの確認

活用シーン

  1. 01ABP アプリケーションサービスの設計
  2. 02エンティティから DTO へのマッピング
  3. 03フィルター付きリスト API の作成
  4. 04手動プロパティマッピングの確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/thapaliyabikendra/ai-artifacts --skill abp-service-patterns

インストール後、Claude Code で/abp-service-patternsを入力して読み込まれたか確認します。

詳細

ABP サービスパターン

ABP Framework のアプリケーション層のパターンです。

アプリケーションサービスパターン

public class PatientAppService : ApplicationService, IPatientAppService
{
    private readonly IRepository<Patient, Guid> _patientRepository;
    private readonly PatientManager _patientManager;  // Domain service
    private readonly ClinicApplicationMappers _mapper;

    public PatientAppService(
        IRepository<Patient, Guid> patientRepository,
        PatientManager patientManager,
        ClinicApplicationMappers mapper)
    {
        _patientRepository = patientRepository;
        _patientManager = patientManager;
        _mapper = mapper;
    }

    [Authorize(ClinicPermissions.Patients.Default)]
    public async Task<PatientDto> GetAsync(Guid id)
    {
        var patient = await _patientRepository.GetAsync(id);
        return _mapper.PatientToDto(patient);
    }

    [Authorize(ClinicPermissions.Patients.Create)]
    public async Task<PatientDto> CreateAsync(CreatePatientDto input)
    {
        var patient = await _patientManager.CreateAsync(
            input.FirstName, input.LastName, input.Email, input.DateOfBirth);
        return _mapper.PatientToDto(patient);
    }

    [Authorize(ClinicPermissions.Patients.Edit)]
    public async Task<PatientDto> UpdateAsync(Guid id, UpdatePatientDto input)
    {
        var patient = await _patientRepository.GetAsync(id);
        _mapper.UpdatePatientFromDto(input, patient);
        await _patientRepository.UpdateAsync(patient);
        return _mapper.PatientToDto(patient);
    }

    [Authorize(ClinicPermissions.Patients.Delete)]
    public async Task DeleteAsync(Guid id)
    {
        await _patientRepository.DeleteAsync(id);
    }
}

Mapperly によるオブジェクトマッピング

ABP 10.x では、AutoMapper の代わりに Mapperly(ソースジェネレーター)を使用します。

// Application/ClinicApplicationMappers.cs
[Mapper]
public partial class ClinicApplicationMappers
{
    // Entity to DTO
    public partial PatientDto PatientToDto(Patient patient);
    public partial List<PatientDto> PatientsToDtos(List<Patient> patients);

    // DTO to Entity (creation)
    public partial Patient CreateDtoToPatient(CreatePatientDto dto);

    // DTO to Entity (update) - ignores Id
    [MapperIgnoreTarget(nameof(Patient.Id))]
    public partial void UpdatePatientFromDto(UpdatePatientDto dto, Patient patient);

    // Complex mapping with navigation properties
    [MapProperty(nameof(Appointment.Patient.FirstName), nameof(AppointmentDto.PatientName))]
    [MapProperty(nameof(Appointment.Doctor.FullName), nameof(AppointmentDto.DoctorName))]
    public partial AppointmentDto AppointmentToDto(Appointment appointment);
}

モジュールに登録:

public override void ConfigureServices(ServiceConfigurationContext context)
{
    context.Services.AddSingleton<ClinicApplicationMappers>();
}

Unit of Work

ABP はアプリケーションサービスのメソッドに対する UoW を自動的に管理します。

public class AppointmentAppService : ApplicationService
{
    // This method is automatically wrapped in a UoW
    // All changes are committed together or rolled back on exception
    public async Task<AppointmentDto> CreateAsync(CreateAppointmentDto input)
    {
        var patient = await _patientRepository.GetAsync(input.PatientId);
        patient.LastAppointmentDate = input.AppointmentDate;

        var appointment = new Appointment(
            GuidGenerator.Create(),
            input.PatientId,
            input.DoctorId,
            input.AppointmentDate);

        await _appointmentRepository.InsertAsync(appointment);

        // Both changes committed together automatically
        return _mapper.AppointmentToDto(appointment);
    }
}

UoW の手動制御:

[UnitOfWork(isTransactional: false)]  // Disable for read-only
public async Task GenerateLargeReportAsync() { }

public async Task ProcessBatchAsync(List<Guid> ids)
{
    foreach (var id in ids)
    {
        using (var uow = _unitOfWorkManager.Begin(requiresNew: true))
        {
            await ProcessItemAsync(id);
            await uow.CompleteAsync();
        }
    }
}

フィルター DTO パターン

クエリフィルターとページネーションを分離し、明確で自己文書化された API にします。

フィルター DTO:

public class PatientFilter
{
    public Guid? DoctorId { get; set; }
    public string? Name { get; set; }
    public string? Email { get; set; }
    public bool? IsActive { get; set; }
    public DateTime? CreatedAfter { get; set; }
    public DateTime? CreatedBefore { get; set; }
}

WhereIf を使用する AppService:

public async Task<PagedResultDto<PatientDto>> GetListAsync(
    PagedAndSortedResultRequestDto input,
    PatientFilter filter)
{
    // Trim string inputs
    filter.Name = filter.Name?.Trim();
    filter.Email = filter.Email?.Trim();

    // Default sorting
    if (input.Sorting.IsNullOrWhiteSpace())
        input.Sorting = nameof(PatientDto.FirstName);

    var queryable = await _patientRepository.GetQueryableAsync();

    var query = queryable
        .WhereIf(filter.DoctorId.HasValue, x => x.DoctorId == filter.DoctorId)
        .WhereIf(!filter.Name.IsNullOrWhiteSpace(),
            x => x.FirstName.Contains(filter.Name) || x.LastName.Contains(filter.Name))
        .WhereIf(!filter.Email.IsNullOrWhiteSpace(),
            x => x.Email.ToLower().Contains(filter.Email.ToLower()))
        .WhereIf(filter.IsActive.HasValue, x => x.IsActive == filter.IsActive)
        .WhereIf(filter.CreatedAfter.HasValue, x => x.CreationTime >= filter.CreatedAfter)
        .WhereIf(filter.CreatedBefore.HasValue, x => x.CreationTime <= filter.CreatedBefore);

    var totalCount = await AsyncExecuter.CountAsync(query);

    var patients = await AsyncExecuter.ToListAsync(
        query.OrderBy(input.Sorting).PageBy(input.SkipCount, input.MaxResultCount));

    return new PagedResultDto<PatientDto>(totalCount, _mapper.PatientsToDtos(patients));
}

ResponseModel ラッパー

public class ResponseModel<T>
{
    public bool IsSuccess { get; set; }
    public T Data { get; set; }
    public string Message { get; set; }

    public static ResponseModel<T> Success(T data, string message = null)
        => new() { IsSuccess = true, Data = data, Message = message };

    public static ResponseModel<T> Failure(string message)
        => new() { IsSuccess = false, Message = message };
}

// Usage
public async Task<ResponseModel<PatientDto>> GetAsync(Guid id)
{
    var patient = await _patientRepository.FirstOrDefaultAsync(x => x.Id == id);
    if (patient == null)
        return ResponseModel<PatientDto>.Failure("Patient not found");

    return ResponseModel<PatientDto>.Success(_mapper.PatientToDto(patient));
}

CommonDependencies パターン

横断的関心事に関する依存関係をまとめ、コンストラクターの肥大化を抑えます。

public class CommonDependencies<T>
{
    public IDistributedEventBus DistributedEventBus { get; set; }
    public IDataFilter DataFilter { get; set; }
    public ILogger<T> Logger { get; set; }
    public IGuidGenerator GuidGenerator { get; set; }
}

// Register
context.Services.AddTransient(typeof(CommonDependencies<>));

// Usage
public class PatientAppService : ApplicationService
{
    private readonly IRepository<Patient, Guid> _patientRepository;
    private readonly CommonDependencies<PatientAppService> _common;

    public PatientAppService(
        IRepository<Patient, Guid> patientRepository,
        CommonDependencies<PatientAppService> common)
    {
        _patientRepository = patientRepository;
        _common = common;
    }

    public async Task<PatientDto> CreateAsync(CreatePatientDto input)
    {
        _common.Logger.LogInformation("Creating patient: {Name}", input.FirstName);
        var patient = new Patient(_common.GuidGenerator.Create(), /*...*/);
        await _patientRepository.InsertAsync(patient);
        await _common.DistributedEventBus.PublishAsync(new PatientCreatedEto { Id = patient.Id });
        return _mapper.PatientToDto(patient);
    }
}

構造化ログ

public async Task<PatientDto> CreateAsync(CreatePatientDto input)
{
    _logger.LogInformation(
        "[{Service}] {Method} - Started - Input: {@Input}",
        nameof(PatientAppService), nameof(CreateAsync), input);

    try
    {
        var patient = await _patientManager.CreateAsync(/*...*/);

        _logger.LogInformation(
            "[{Service}] {Method} - Completed - PatientId: {PatientId}",
            nameof(PatientAppService), nameof(CreateAsync), patient.Id);

        return _mapper.PatientToDto(patient);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex,
            "[{Service}] {Method} - Failed - Error: {Message}",
            nameof(PatientAppService), nameof(CreateAsync), ex.Message);
        throw;
    }
}

入力のサニタイズ

public static class InputSanitization
{
    public static string TrimAndLower(this string value) => value?.Trim()?.ToLowerInvariant();
    public static string TrimAndUpper(this string value) => value?.Trim()?.ToUpperInvariant();
}

// Usage
public async Task<PatientDto> CreateAsync(CreatePatientDto input)
{
    input.Email = input.Email.TrimAndLower();
    input.FirstName = input.FirstName?.Trim();
    // ...
}

マッピングの検証パターン

よくあるバグ:コピー&ペーストによるプロパティマッピング

手動マッピング(特に select new 句)は、コピー&ペーストによるミスが発生しやすくなります。

// ❌ BUG: Wrong property copied - IsPutawayCompleted mapped from wrong source!
select new LicensePlateDto()
{
    IsInboundQCChecklistCompleted = lc.IsInboundQCChecklistCompleted,
    IsPutawayCompleted = lc.IsInboundQCChecklistCompleted,  // BUG! Should be lc.IsPutawayCompleted
    IsHold = lc.IsHold
}

// ✅ CORRECT: Use Mapperly to prevent copy-paste errors
[Mapper]
public partial class LicensePlateMapper
{
    public partial LicensePlateDto ToDto(LicensePlate entity);
}

// Or if manual mapping is required, double-check similar-named properties
select new LicensePlateDto()
{
    IsInboundQCChecklistCompleted = lc.IsInboundQCChecklistCompleted,
    IsPutawayCompleted = lc.IsPutawayCompleted,  // ✅ Correct property
    IsHold = lc.IsHold
}
手動マッピングのチェックリスト

手動マッピングが避けられない場合(例:複雑なプロジェクション)は、以下を確認してください。

  • 各 DTO プロパティが正しいエンティティプロパティにマッピングされている
  • 名前が似ているプロパティを再確認する(例:IsXxxCompleted と IsYyyCompleted)
  • オプションのナビゲーションプロパティに対する null チェック
  • 隣接する行から、変更せずにコピー&ペーストしていない
リスクの高いプロパティのパターン

次のように似ているパターンには、特に注意してください。

DTO プロパティ誤ったソース正しいソース
IsPutawayCompletedentity.IsInboundCompletedentity.IsPutawayCompleted
UpdatedAtentity.CreatedAtentity.LastModificationTime
CustomerNameentity.ShipperNameentity.CustomerName
TargetDateentity.SourceDateentity.TargetDate

ベストプラクティス

  1. 薄い AppServices - オーケストレーションに徹し、ビジネスロジックは実装しない
  2. ドメインに委譲する - 複雑なルールにはドメインサービスを使用する
  3. Mapperly を使用する - パフォーマンスに優れたソース生成マッピングを使用する(コピー&ペーストによるバグを防止)
  4. WhereIf パターン - オプションのフィルタリングをすっきり記述する
  5. 構造化ログ - トレースに一貫した形式を使用する
  6. 入力のサニタイズ - 入力をトリミングして正規化する
  7. 認可 - 常に権限を確認する
  8. 手動マッピングを検証する - 名前が似ているプロパティの割り当てを再確認する

関連スキル

  • abp-entity-patterns - ドメイン層のパターン
  • abp-infrastructure-patterns - 横断的関心事
  • fluentvalidation-patterns - 入力検証

コメント