Skip to main content

Overview

The Media module manages file uploads and storage using Azure Blob Storage. It handles photos and videos for products, categories, and user profiles.

Bounded Context

The Media module is responsible for:
  • Blob resource creation and deletion
  • Azure Blob Storage integration
  • Image and video upload/download
  • Media URL generation
  • Media type validation
  • Cleanup of orphaned media files

Domain Layer

Location: Media.Domain/

BlobResource Aggregate

Root: BlobResource entity
BlobAggregate/BlobResource.cs
public sealed class BlobResource : BaseEntity
{
    public string Name { get; private set; }
    public string Url { get; private set; } = string.Empty;
    public BlobResourceType Type { get; private set; }
    
    private BlobResource() { }
    
    private BlobResource(string name, BlobResourceType type)
    {
        Name = name;
        Type = type;
    }
    
    public static Result<BlobResource> Create(string type)
    {
        if (!Enum.TryParse(type, out BlobResourceType blobResourceType))
            return Result<BlobResource>.Failure("Invalid blob resource type");
        
        // Generate unique name with type suffix
        var name = $"{Guid.NewGuid():N}-{type.ToLowerInvariant()}";
        
        BlobResource blobResource = new(name, blobResourceType);
        return Result<BlobResource>.Success(blobResource);
    }
    
    public static Result<BlobResource> Create(BlobResourceType type)
    {
        if (!Enum.IsDefined(type))
            return Result<BlobResource>.Failure("Invalid blob resource type");
        
        var name = $"{Guid.NewGuid():N}-{type.ToString().ToLowerInvariant()}";
        
        BlobResource blobResource = new(name, type);
        return Result<BlobResource>.Success(blobResource);
    }
    
    public VoidResult ChangeName(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            return VoidResult.Failure("Name is required");
        
        Name = name;
        return VoidResult.Success();
    }
    
    public VoidResult ChangeUrl(string url)
    {
        if (string.IsNullOrWhiteSpace(url))
            return VoidResult.Failure("URL is required");
        
        Url = url;
        return VoidResult.Success();
    }
    
    public VoidResult ChangeType(string type)
    {
        if (!Enum.TryParse(type, out BlobResourceType blobResourceType))
            return VoidResult.Failure("Invalid blob resource type");
        
        Type = blobResourceType;
        return VoidResult.Success();
    }
}
Enums (from Shared.Domain):
Shared.Domain/Enums/BlobResourceType.cs
public enum BlobResourceType
{
    Photo,
    Video
}

Repository Interfaces

public interface IBlobResourceRepository : IBaseRepository<BlobResource>
{
    Task<BlobResource?> GetByNameAsync(string name, CancellationToken ct);
    Task<IReadOnlyCollection<BlobResource>> GetByTypeAsync(
        BlobResourceType type, CancellationToken ct);
}

Application Layer

Location: Media.Application/

Services

Services/MediaService.cs
public sealed class MediaService
{
    private const string ContainerName = "wolfix-media";
    private readonly string[] AllowedPhotoExtensions = { ".jpg", ".jpeg", ".png", ".webp" };
    private readonly string[] AllowedVideoExtensions = { ".mp4", ".mov", ".avi" };
    private const long MaxPhotoSize = 5 * 1024 * 1024;  // 5 MB
    private const long MaxVideoSize = 50 * 1024 * 1024; // 50 MB
    
    public async Task<Result<CreatedMediaDto>> UploadPhotoAsync(
        IFormFile file,
        CancellationToken ct)
    {
        // Validate file
        VoidResult validationResult = ValidatePhoto(file);
        if (validationResult.IsFailure)
            return Result<CreatedMediaDto>.Failure(validationResult);
        
        // Create blob resource
        Result<BlobResource> createResult = BlobResource.Create(BlobResourceType.Photo);
        if (createResult.IsFailure)
            return Result<CreatedMediaDto>.Failure(createResult);
        
        BlobResource blobResource = createResult.Value!;
        
        // Upload to Azure Blob Storage
        string url;
        try
        {
            await using var stream = file.OpenReadStream();
            url = await _azureBlobRepository.UploadAsync(
                ContainerName, 
                blobResource.Name, 
                stream, 
                file.ContentType,
                ct);
        }
        catch (Exception ex)
        {
            return Result<CreatedMediaDto>.Failure(
                $"Failed to upload file: {ex.Message}", 
                HttpStatusCode.InternalServerError);
        }
        
        // Save blob resource with URL
        blobResource.ChangeUrl(url);
        await _blobResourceRepository.AddAsync(blobResource, ct);
        await _blobResourceRepository.SaveChangesAsync(ct);
        
        var dto = new CreatedMediaDto
        {
            Id = blobResource.Id,
            Url = blobResource.Url,
            Type = blobResource.Type
        };
        
        return Result<CreatedMediaDto>.Success(dto);
    }
    
    public async Task<Result<CreatedMediaDto>> UploadVideoAsync(
        IFormFile file,
        CancellationToken ct)
    {
        // Similar to UploadPhotoAsync but with video validation
        VoidResult validationResult = ValidateVideo(file);
        if (validationResult.IsFailure)
            return Result<CreatedMediaDto>.Failure(validationResult);
        
        Result<BlobResource> createResult = BlobResource.Create(BlobResourceType.Video);
        // ... rest of upload logic
    }
    
    public async Task<VoidResult> DeleteMediaAsync(
        Guid mediaId,
        CancellationToken ct)
    {
        BlobResource? blobResource = await _blobResourceRepository
            .GetByIdAsync(mediaId, ct);
        if (blobResource == null)
            return VoidResult.Failure("Media not found", HttpStatusCode.NotFound);
        
        // Delete from Azure Blob Storage
        try
        {
            await _azureBlobRepository.DeleteAsync(
                ContainerName, 
                blobResource.Name, 
                ct);
        }
        catch (Exception ex)
        {
            return VoidResult.Failure(
                $"Failed to delete blob: {ex.Message}", 
                HttpStatusCode.InternalServerError);
        }
        
        // Delete from database
        _blobResourceRepository.Delete(blobResource, ct);
        await _blobResourceRepository.SaveChangesAsync(ct);
        
        return VoidResult.Success();
    }
    
    private VoidResult ValidatePhoto(IFormFile file)
    {
        if (file == null || file.Length == 0)
            return VoidResult.Failure("File is required");
        
        if (file.Length > MaxPhotoSize)
            return VoidResult.Failure("Photo size exceeds 5 MB limit");
        
        var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
        if (!AllowedPhotoExtensions.Contains(extension))
            return VoidResult.Failure(
                $"Invalid file type. Allowed: {string.Join(", ", AllowedPhotoExtensions)}");
        
        return VoidResult.Success();
    }
    
    private VoidResult ValidateVideo(IFormFile file)
    {
        if (file == null || file.Length == 0)
            return VoidResult.Failure("File is required");
        
        if (file.Length > MaxVideoSize)
            return VoidResult.Failure("Video size exceeds 50 MB limit");
        
        var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
        if (!AllowedVideoExtensions.Contains(extension))
            return VoidResult.Failure(
                $"Invalid file type. Allowed: {string.Join(", ", AllowedVideoExtensions)}");
        
        return VoidResult.Success();
    }
}

Integration Event Handlers

EventHandlers/DeleteProductMediaHandler.cs
public class DeleteProductMediaHandler : 
    IIntegrationEventHandler<DeleteProductMedia>
{
    public async Task<VoidResult> HandleAsync(
        DeleteProductMedia @event, 
        CancellationToken ct)
    {
        return await _mediaService.DeleteMediaAsync(@event.MediaId, ct);
    }
}
EventHandlers/UploadCategoryPhotoHandler.cs
public class UploadCategoryPhotoHandler : 
    IIntegrationEventHandler<AddPhotoForNewCategory>
{
    public async Task<VoidResult> HandleAsync(
        AddPhotoForNewCategory @event, 
        CancellationToken ct)
    {
        // This handler is triggered after category creation
        // to inform Catalog module of the uploaded photo URL
        BlobResource? blobResource = await _blobResourceRepository
            .GetByIdAsync(@event.BlobResourceId, ct);
        
        if (blobResource == null)
            return VoidResult.Failure("Blob resource not found");
        
        // Notify Catalog module with the URL
        await _eventBus.PublishWithoutResultAsync(
            new CategoryPhotoUploaded(@event.CategoryId, blobResource.Url), ct);
        
        return VoidResult.Success();
    }
}

Infrastructure Layer

Location: Media.Infrastructure/

Azure Blob Repository Implementation

Repositories/AzureBlobRepository.cs
public class AzureBlobRepository : IAzureBlobRepository
{
    private readonly BlobServiceClient _blobServiceClient;
    
    public AzureBlobRepository(IConfiguration configuration)
    {
        string connectionString = configuration["Azure:BlobStorage:ConnectionString"]!;
        _blobServiceClient = new BlobServiceClient(connectionString);
    }
    
    public async Task<string> UploadAsync(
        string containerName, 
        string blobName, 
        Stream content, 
        string contentType,
        CancellationToken ct)
    {
        var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
        await containerClient.CreateIfNotExistsAsync(cancellationToken: ct);
        
        var blobClient = containerClient.GetBlobClient(blobName);
        
        var options = new BlobUploadOptions
        {
            HttpHeaders = new BlobHttpHeaders
            {
                ContentType = contentType
            }
        };
        
        await blobClient.UploadAsync(content, options, ct);
        
        return blobClient.Uri.ToString();
    }
    
    public async Task<Stream> DownloadAsync(
        string containerName, 
        string blobName, 
        CancellationToken ct)
    {
        var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
        var blobClient = containerClient.GetBlobClient(blobName);
        
        var response = await blobClient.DownloadStreamingAsync(cancellationToken: ct);
        return response.Value.Content;
    }
    
    public async Task DeleteAsync(
        string containerName, 
        string blobName, 
        CancellationToken ct)
    {
        var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
        var blobClient = containerClient.GetBlobClient(blobName);
        
        await blobClient.DeleteIfExistsAsync(cancellationToken: ct);
    }
    
    public async Task<bool> ExistsAsync(
        string containerName, 
        string blobName, 
        CancellationToken ct)
    {
        var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
        var blobClient = containerClient.GetBlobClient(blobName);
        
        return await blobClient.ExistsAsync(ct);
    }
    
    public string GetBlobUrl(string containerName, string blobName)
    {
        var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
        var blobClient = containerClient.GetBlobClient(blobName);
        return blobClient.Uri.ToString();
    }
}

Repository Implementation

Repositories/BlobResourceRepository.cs
public class BlobResourceRepository : 
    BaseRepository<MediaContext, BlobResource>, IBlobResourceRepository
{
    public async Task<BlobResource?> GetByNameAsync(string name, CancellationToken ct)
    {
        return await _context.BlobResources
            .FirstOrDefaultAsync(br => br.Name == name, ct);
    }
    
    public async Task<IReadOnlyCollection<BlobResource>> GetByTypeAsync(
        BlobResourceType type, 
        CancellationToken ct)
    {
        return await _context.BlobResources
            .Where(br => br.Type == type)
            .ToListAsync(ct);
    }
}

EF Core Configuration

Configurations/BlobResourceConfiguration.cs
public class BlobResourceConfiguration : IEntityTypeConfiguration<BlobResource>
{
    public void Configure(EntityTypeBuilder<BlobResource> builder)
    {
        builder.HasKey(br => br.Id);
        
        builder.Property(br => br.Name).IsRequired().HasMaxLength(500);
        builder.HasIndex(br => br.Name).IsUnique();
        
        builder.Property(br => br.Url).HasMaxLength(1000);
        builder.Property(br => br.Type).IsRequired();
    }
}

Endpoints Layer

Location: Media.Endpoints/
Endpoints/MediaEndpoints.cs
internal static class MediaEndpoints
{
    public static void MapMediaEndpoints(this IEndpointRouteBuilder app)
    {
        var mediaGroup = app.MapGroup("api/media")
            .WithTags("Media")
            .DisableAntiforgery();  // For file uploads
        
        mediaGroup.MapPost("upload/photo", UploadPhoto)
            .RequireAuthorization()
            .Accepts<IFormFile>("multipart/form-data")
            .WithSummary("Upload a photo");
        
        mediaGroup.MapPost("upload/video", UploadVideo)
            .RequireAuthorization()
            .Accepts<IFormFile>("multipart/form-data")
            .WithSummary("Upload a video");
        
        mediaGroup.MapDelete("{id:guid}", DeleteMedia)
            .RequireAuthorization()
            .WithSummary("Delete media file");
        
        mediaGroup.MapGet("{id:guid}", GetMedia)
            .WithSummary("Get media information");
    }
    
    private static async Task<Results<Ok<CreatedMediaDto>, BadRequest<string>>> UploadPhoto(
        IFormFile file,
        [FromServices] MediaService mediaService,
        CancellationToken ct)
    {
        var result = await mediaService.UploadPhotoAsync(file, ct);
        return result.IsSuccess 
            ? TypedResults.Ok(result.Value!) 
            : TypedResults.BadRequest(result.ErrorMessage!);
    }
}

Configuration

Azure Blob Storage

appsettings.json
{
  "Azure": {
    "BlobStorage": {
      "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=..."
    }
  }
}

Dependency Injection

Extensions/DependencyInjection.cs
public static IServiceCollection AddMediaInfrastructure(
    this IServiceCollection services,
    IConfiguration configuration)
{
    services.AddSingleton<IAzureBlobRepository, AzureBlobRepository>();
    services.AddScoped<IBlobResourceRepository, BlobResourceRepository>();
    
    return services;
}

Integration Events

Published

Media.IntegrationEvents/
public record CategoryPhotoUploaded(Guid CategoryId, string PhotoUrl) : IIntegrationEvent;
public record ProductMediaUploaded(Guid ProductId, Guid MediaId, string MediaUrl) : IIntegrationEvent;

Consumed

  • DeleteProductMedia - From Catalog module
  • AddPhotoForNewCategory - From Catalog module
  • Catalog - Product and category media
  • Customer - Customer profile photos
  • Seller - Seller profile photos

Build docs developers (and LLMs) love