Thursday, August 7, 2025
0 comments

ASP.NET Core with Angular Invoice Management System using CQRS Pattern

12:47 PM

 


ASP.NET Core with Angular Invoice Management System using CQRS Pattern

Table of Contents

  1. Introduction to CQRS

  2. System Architecture

  3. Project Structure

  4. Domain Layer Implementation

  5. Application Layer (CQRS)

  6. Infrastructure Layer

  7. API Layer

  8. Angular Frontend

  9. Database Design

  10. Deployment to IIS

  11. Performance Optimization

  12. Security Considerations

  13. Testing Strategy

  14. Monitoring and Logging

  15. Conclusion

Introduction to CQRS

Command Query Responsibility Segregation (CQRS) is an architectural pattern that separates read and write operations for a data store. In traditional architectures, the same data model is used to query and update a database. CQRS separates these into different models.

Benefits of CQRS for Invoice Management

  1. Scalability: Read and write workloads can scale independently

  2. Optimization: Queries and commands can be optimized separately

  3. Simplified Models: Each side can focus on its specific responsibility

  4. Flexibility: Easier to evolve the system over time

  5. Performance: Can use different data access techniques for reads vs writes

CQRS Implementation Approaches

We'll implement a vertical slice architecture with these components:

  • Commands: Represent write operations (Create, Update, Delete)

  • Queries: Represent read operations (Get, List, Search)

  • Handlers: Process commands and queries

  • Domain Models: Core business entities

  • DTOs: Data transfer objects for API contracts

System Architecture

Our invoice management system will follow this architecture:

text
InvoiceManagement/
├── API/                  # ASP.NET Core Web API
├── Application/          # CQRS Handlers, DTOs
│   ├── Commands/         # Command definitions and handlers
│   ├── Queries/          # Query definitions and handlers
│   └── Common/           # Shared interfaces and helpers
├── Domain/               # Core domain models
│   ├── Entities/         # Aggregate roots and entities
│   ├── ValueObjects/     # Value objects
│   └── Enums/            # Domain enums
├── Infrastructure/       # Data access, external services
│   ├── Data/             # DbContext, repositories
│   ├── Identity/         # Authentication services
│   └── Services/         # Other infrastructure services
└── ClientApp/            # Angular frontend

Project Structure

Solution Projects

  1. InvoiceManagement.API: ASP.NET Core Web API project

  2. InvoiceManagement.Application: CQRS handlers, DTOs, interfaces

  3. InvoiceManagement.Domain: Core domain models and business rules

  4. InvoiceManagement.Infrastructure: Data access, identity, external services

  5. InvoiceManagement.Client: Angular frontend (optional separate project)

Key NuGet Packages

xml
<!-- API Project -->
<PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="FluentValidation" Version="11.5.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.11" />

<!-- Application Project -->
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="MediatR" Version="12.1.1" />

<!-- Infrastructure Project -->
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.11" />

Domain Layer Implementation

Aggregate Roots

Invoice.cs

csharp
namespace InvoiceManagement.Domain.Entities
{
    public class Invoice : AuditableEntity, IAggregateRoot
    {
        public int Id { get; private set; }
        public string InvoiceNumber { get; private set; }
        public DateTime InvoiceDate { get; private set; }
        public DateTime DueDate { get; private set; }
        public decimal Subtotal { get; private set; }
        public decimal TaxAmount { get; private set; }
        public decimal DiscountAmount { get; private set; }
        public decimal TotalAmount { get; private set; }
        public string Notes { get; private set; }
        public InvoiceStatus Status { get; private set; }
        
        // Foreign keys
        public int CustomerId { get; private set; }
        
        // Navigation properties
        public Customer Customer { get; private set; }
        private readonly List<InvoiceItem> _items = new List<InvoiceItem>();
        public IReadOnlyCollection<InvoiceItem> Items => _items.AsReadOnly();

        // Private constructor for EF Core
        private Invoice() { }
        
        public Invoice(string invoiceNumber, DateTime invoiceDate, DateTime dueDate, 
            int customerId, string notes = null)
        {
            InvoiceNumber = invoiceNumber;
            InvoiceDate = invoiceDate;
            DueDate = dueDate;
            CustomerId = customerId;
            Notes = notes;
            Status = InvoiceStatus.Draft;
            
            AddDomainEvent(new InvoiceCreatedEvent(this));
        }
        
        public void AddItem(int productId, int quantity, decimal unitPrice, 
            decimal discount = 0, decimal taxRate = 0, string notes = null)
        {
            var item = new InvoiceItem(Id, productId, quantity, unitPrice, discount, taxRate, notes);
            _items.Add(item);
            
            CalculateTotals();
        }
        
        public void RemoveItem(int invoiceItemId)
        {
            var item = _items.FirstOrDefault(i => i.Id == invoiceItemId);
            if (item != null)
            {
                _items.Remove(item);
                CalculateTotals();
            }
        }
        
        private void CalculateTotals()
        {
            Subtotal = _items.Sum(i => i.Quantity * i.UnitPrice);
            DiscountAmount = _items.Sum(i => i.Quantity * i.UnitPrice * i.Discount / 100);
            TaxAmount = _items.Sum(i => (i.Quantity * i.UnitPrice * (1 - i.Discount / 100)) * (i.TaxRate / 100));
            TotalAmount = Subtotal - DiscountAmount + TaxAmount;
        }
        
        public void MarkAsSent()
        {
            if (Status != InvoiceStatus.Draft)
                throw new InvalidOperationException("Only draft invoices can be marked as sent");
                
            Status = InvoiceStatus.Sent;
            AddDomainEvent(new InvoiceSentEvent(this));
        }
        
        // Other status change methods...
    }
}

Value Objects

Address.cs

csharp
namespace InvoiceManagement.Domain.ValueObjects
{
    public class Address : ValueObject
    {
        public string Street { get; }
        public string City { get; }
        public string State { get; }
        public string ZipCode { get; }
        public string Country { get; }
        
        public Address(string street, string city, string state, string zipCode, string country)
        {
            Street = street;
            City = city;
            State = state;
            ZipCode = zipCode;
            Country = country;
        }
        
        protected override IEnumerable<object> GetEqualityComponents()
        {
            yield return Street;
            yield return City;
            yield return State;
            yield return ZipCode;
            yield return Country;
        }
    }
}

Domain Events

InvoiceCreatedEvent.cs

csharp
namespace InvoiceManagement.Domain.Events
{
    public class InvoiceCreatedEvent : DomainEvent
    {
        public Invoice Invoice { get; }
        
        public InvoiceCreatedEvent(Invoice invoice)
        {
            Invoice = invoice;
        }
    }
}

Application Layer (CQRS)

Commands

CreateInvoiceCommand.cs

csharp
namespace InvoiceManagement.Application.Invoices.Commands
{
    public class CreateInvoiceCommand : IRequest<int>
    {
        public string InvoiceNumber { get; set; }
        public DateTime InvoiceDate { get; set; }
        public DateTime DueDate { get; set; }
        public int CustomerId { get; set; }
        public string Notes { get; set; }
        public List<InvoiceItemDto> Items { get; set; } = new List<InvoiceItemDto>();
    }
    
    public class InvoiceItemDto
    {
        public int ProductId { get; set; }
        public int Quantity { get; set; }
        public decimal UnitPrice { get; set; }
        public decimal Discount { get; set; }
        public decimal TaxRate { get; set; }
        public string Notes { get; set; }
    }
}

CreateInvoiceCommandHandler.cs

csharp
namespace InvoiceManagement.Application.Invoices.Commands
{
    public class CreateInvoiceCommandHandler : IRequestHandler<CreateInvoiceCommand, int>
    {
        private readonly IApplicationDbContext _context;
        private readonly IMapper _mapper;
        
        public CreateInvoiceCommandHandler(IApplicationDbContext context, IMapper mapper)
        {
            _context = context;
            _mapper = mapper;
        }
        
        public async Task<int> Handle(CreateInvoiceCommand request, CancellationToken cancellationToken)
        {
            var entity = new Invoice(
                request.InvoiceNumber,
                request.InvoiceDate,
                request.DueDate,
                request.CustomerId,
                request.Notes);
                
            foreach (var itemDto in request.Items)
            {
                entity.AddItem(
                    itemDto.ProductId,
                    itemDto.Quantity,
                    itemDto.UnitPrice,
                    itemDto.Discount,
                    itemDto.TaxRate,
                    itemDto.Notes);
            }
            
            _context.Invoices.Add(entity);
            
            await _context.SaveChangesAsync(cancellationToken);
            
            return entity.Id;
        }
    }
}

Queries

GetInvoiceDetailQuery.cs

csharp
namespace InvoiceManagement.Application.Invoices.Queries
{
    public class GetInvoiceDetailQuery : IRequest<InvoiceDetailVm>
    {
        public int Id { get; set; }
    }
    
    public class InvoiceDetailVm
    {
        public int Id { get; set; }
        public string InvoiceNumber { get; set; }
        public DateTime InvoiceDate { get; set; }
        public DateTime DueDate { get; set; }
        public decimal Subtotal { get; set; }
        public decimal TaxAmount { get; set; }
        public decimal DiscountAmount { get; set; }
        public decimal TotalAmount { get; set; }
        public string Notes { get; set; }
        public InvoiceStatus Status { get; set; }
        public CustomerDto Customer { get; set; }
        public List<InvoiceItemDto> Items { get; set; } = new List<InvoiceItemDto>();
    }
    
    public class CustomerDto
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Email { get; set; }
    }
    
    public class InvoiceItemDto
    {
        public int Id { get; set; }
        public int ProductId { get; set; }
        public string ProductName { get; set; }
        public int Quantity { get; set; }
        public decimal UnitPrice { get; set; }
        public decimal Discount { get; set; }
        public decimal TaxRate { get; set; }
        public string Notes { get; set; }
        public decimal LineTotal => (Quantity * UnitPrice * (1 - Discount / 100)) * (1 + TaxRate / 100);
    }
}

GetInvoiceDetailQueryHandler.cs

csharp
namespace InvoiceManagement.Application.Invoices.Queries
{
    public class GetInvoiceDetailQueryHandler : IRequestHandler<GetInvoiceDetailQuery, InvoiceDetailVm>
    {
        private readonly IApplicationDbContext _context;
        private readonly IMapper _mapper;
        
        public GetInvoiceDetailQueryHandler(IApplicationDbContext context, IMapper mapper)
        {
            _context = context;
            _mapper = mapper;
        }
        
        public async Task<InvoiceDetailVm> Handle(GetInvoiceDetailQuery request, CancellationToken cancellationToken)
        {
            var entity = await _context.Invoices
                .Include(i => i.Customer)
                .Include(i => i.Items)
                .ThenInclude(i => i.Product)
                .FirstOrDefaultAsync(i => i.Id == request.Id, cancellationToken);
                
            if (entity == null)
            {
                throw new NotFoundException(nameof(Invoice), request.Id);
            }
            
            return _mapper.Map<InvoiceDetailVm>(entity);
        }
    }
}

Validation

CreateInvoiceCommandValidator.cs

csharp
namespace InvoiceManagement.Application.Invoices.Commands
{
    public class CreateInvoiceCommandValidator : AbstractValidator<CreateInvoiceCommand>
    {
        public CreateInvoiceCommandValidator()
        {
            RuleFor(v => v.InvoiceNumber)
                .NotEmpty().WithMessage("Invoice number is required")
                .MaximumLength(20).WithMessage("Invoice number must not exceed 20 characters");
                
            RuleFor(v => v.InvoiceDate)
                .NotEmpty().WithMessage("Invoice date is required")
                .LessThanOrEqualTo(DateTime.Today).WithMessage("Invoice date cannot be in the future");
                
            RuleFor(v => v.DueDate)
                .NotEmpty().WithMessage("Due date is required")
                .GreaterThanOrEqualTo(v => v.InvoiceDate).WithMessage("Due date must be after or equal to invoice date");
                
            RuleFor(v => v.CustomerId)
                .NotEmpty().WithMessage("Customer is required");
                
            RuleFor(v => v.Items)
                .NotEmpty().WithMessage("At least one invoice item is required");
                
            RuleForEach(v => v.Items).SetValidator(new InvoiceItemValidator());
        }
    }
    
    public class InvoiceItemValidator : AbstractValidator<InvoiceItemDto>
    {
        public InvoiceItemValidator()
        {
            RuleFor(v => v.ProductId)
                .NotEmpty().WithMessage("Product is required");
                
            RuleFor(v => v.Quantity)
                .GreaterThan(0).WithMessage("Quantity must be greater than 0");
                
            RuleFor(v => v.UnitPrice)
                .GreaterThan(0).WithMessage("Unit price must be greater than 0");
                
            RuleFor(v => v.Discount)
                .InclusiveBetween(0, 100).WithMessage("Discount must be between 0 and 100");
                
            RuleFor(v => v.TaxRate)
                .InclusiveBetween(0, 100).WithMessage("Tax rate must be between 0 and 100");
        }
    }
}

Infrastructure Layer

Data Access

ApplicationDbContext.cs

csharp
namespace InvoiceManagement.Infrastructure.Data
{
    public class ApplicationDbContext : DbContext, IApplicationDbContext
    {
        private readonly IMediator _mediator;
        
        public ApplicationDbContext(
            DbContextOptions<ApplicationDbContext> options,
            IMediator mediator) : base(options)
        {
            _mediator = mediator;
        }
        
        public DbSet<Invoice> Invoices => Set<Invoice>();
        public DbSet<InvoiceItem> InvoiceItems => Set<InvoiceItem>();
        public DbSet<Customer> Customers => Set<Customer>();
        public DbSet<Product> Products => Set<Product>();
        
        protected override void OnModelCreating(ModelBuilder builder)
        {
            builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
            base.OnModelCreating(builder);
        }
        
        public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
        {
            // Dispatch domain events
            await _mediator.DispatchDomainEvents(this);
            
            // Apply audit information
            var entries = ChangeTracker
                .Entries()
                .Where(e => e.Entity is AuditableEntity && 
                    (e.State == EntityState.Added || e.State == EntityState.Modified));
                    
            foreach (var entry in entries)
            {
                ((AuditableEntity)entry.Entity).LastModified = DateTime.UtcNow;
                
                if (entry.State == EntityState.Added)
                {
                    ((AuditableEntity)entry.Entity).Created = DateTime.UtcNow;
                }
            }
            
            return await base.SaveChangesAsync(cancellationToken);
        }
    }
}

Domain Event Handling

MediatorExtensions.cs

csharp
namespace InvoiceManagement.Infrastructure.Extensions
{
    public static class MediatorExtensions
    {
        public static async Task DispatchDomainEvents(this IMediator mediator, DbContext context)
        {
            var entities = context.ChangeTracker
                .Entries<BaseEntity>()
                .Where(e => e.Entity.DomainEvents.Any())
                .Select(e => e.Entity);
                
            var domainEvents = entities
                .SelectMany(e => e.DomainEvents)
                .ToList();
                
            entities.ToList().ForEach(e => e.ClearDomainEvents());
            
            foreach (var domainEvent in domainEvents)
            {
                await mediator.Publish(domainEvent);
            }
        }
    }
}

API Layer

Controllers

InvoicesController.cs

csharp
namespace InvoiceManagement.API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    [Authorize]
    public class InvoicesController : ControllerBase
    {
        private readonly IMediator _mediator;
        
        public InvoicesController(IMediator mediator)
        {
            _mediator = mediator;
        }
        
        [HttpGet]
        public async Task<ActionResult<PaginatedList<InvoiceListVm>>> GetInvoices([FromQuery] GetInvoiceListQuery query)
        {
            return await _mediator.Send(query);
        }
        
        [HttpGet("{id}")]
        public async Task<ActionResult<InvoiceDetailVm>> GetInvoice(int id)
        {
            var query = new GetInvoiceDetailQuery { Id = id };
            return await _mediator.Send(query);
        }
        
        [HttpPost]
        public async Task<ActionResult<int>> Create(CreateInvoiceCommand command)
        {
            var id = await _mediator.Send(command);
            return CreatedAtAction(nameof(GetInvoice), new { id }, id);
        }
        
        [HttpPut("{id}")]
        public async Task<IActionResult> Update(int id, UpdateInvoiceCommand command)
        {
            if (id != command.Id)
            {
                return BadRequest();
            }
            
            await _mediator.Send(command);
            return NoContent();
        }
        
        [HttpDelete("{id}")]
        public async Task<IActionResult> Delete(int id)
        {
            await _mediator.Send(new DeleteInvoiceCommand { Id = id });
            return NoContent();
        }
        
        [HttpPut("{id}/send")]
        public async Task<IActionResult> Send(int id)
        {
            await _mediator.Send(new SendInvoiceCommand { InvoiceId = id });
            return NoContent();
        }
    }
}

Dependency Injection

DependencyInjection.cs

csharp
namespace InvoiceManagement.API
{
    public static class DependencyInjection
    {
        public static IServiceCollection AddApiServices(this IServiceCollection services, IConfiguration configuration)
        {
            services.AddControllers();
            
            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            services.AddEndpointsApiExplorer();
            services.AddSwaggerGen(c =>
            {
                c.SwaggerDoc("v1", new OpenApiInfo { Title = "Invoice Management API", Version = "v1" });
                
                c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
                {
                    Description = "JWT Authorization header using the Bearer scheme",
                    Name = "Authorization",
                    In = ParameterLocation.Header,
                    Type = SecuritySchemeType.ApiKey,
                    Scheme = "Bearer"
                });
                
                c.AddSecurityRequirement(new OpenApiSecurityRequirement
                {
                    {
                        new OpenApiSecurityScheme
                        {
                            Reference = new OpenApiReference
                            {
                                Type = ReferenceType.SecurityScheme,
                                Id = "Bearer"
                            },
                            Scheme = "oauth2",
                            Name = "Bearer",
                            In = ParameterLocation.Header
                        },
                        new List<string>()
                    }
                });
            });
            
            services.AddCors(options =>
            {
                options.AddPolicy("CorsPolicy", policy => 
                {
                    policy.AllowAnyHeader()
                        .AllowAnyMethod()
                        .WithOrigins(configuration["AllowedOrigins"]);
                });
            });
            
            return services;
        }
    }
}

Angular Frontend

Project Structure

text
src/
├── app/
│   ├── core/               # Core modules and services
│   │   ├── auth/           # Authentication
│   │   ├── interceptors/   # HTTP interceptors
│   │   └── services/       # Core services
│   ├── modules/            # Feature modules
│   │   ├── invoices/       # Invoice management
│   │   ├── customers/      # Customer management
│   │   └── shared/         # Shared components
│   ├── app-routing.module.ts
│   └── app.component.ts
├── assets/
├── environments/
└── styles/

Invoice Service

invoice.service.ts

typescript
@Injectable({
  providedIn: 'root'
})
export class InvoiceService {
  private apiUrl = environment.apiUrl + '/invoices';

  constructor(private http: HttpClient) {}

  getInvoices(params?: any): Observable<InvoiceListDto[]> {
    return this.http.get<InvoiceListDto[]>(this.apiUrl, { params });
  }

  getInvoice(id: number): Observable<InvoiceDetailDto> {
    return this.http.get<InvoiceDetailDto>(`${this.apiUrl}/${id}`);
  }

  createInvoice(invoice: CreateInvoiceDto): Observable<number> {
    return this.http.post<number>(this.apiUrl, invoice);
  }

  updateInvoice(id: number, invoice: UpdateInvoiceDto): Observable<void> {
    return this.http.put<void>(`${this.apiUrl}/${id}`, invoice);
  }

  deleteInvoice(id: number): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/${id}`);
  }

  sendInvoice(id: number): Observable<void> {
    return this.http.put<void>(`${this.apiUrl}/${id}/send`, {});
  }
}

Invoice List Component

invoice-list.component.ts

typescript
@Component({
  selector: 'app-invoice-list',
  templateUrl: './invoice-list.component.html',
  styleUrls: ['./invoice-list.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class InvoiceListComponent implements OnInit, OnDestroy {
  invoices$: Observable<InvoiceListDto[]>;
  isLoading$ = new BehaviorSubject<boolean>(true);
  searchForm: FormGroup;
  private destroy$ = new Subject<void>();

  displayedColumns = ['invoiceNumber', 'date', 'customer', 'totalAmount', 'status', 'actions'];

  constructor(
    private invoiceService: InvoiceService,
    private fb: FormBuilder,
    private router: Router,
    private dialog: MatDialog,
    private cdr: ChangeDetectorRef
  ) {}

  ngOnInit(): void {
    this.initForm();
    this.loadInvoices();
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  private initForm(): void {
    this.searchForm = this.fb.group({
      search: [''],
      status: [''],
      fromDate: [''],
      toDate: ['']
    });

    this.searchForm.valueChanges.pipe(
      debounceTime(500),
      distinctUntilChanged(),
      takeUntil(this.destroy$)
    ).subscribe(() => this.loadInvoices());
  }

  private loadInvoices(): void {
    this.isLoading$.next(true);
    const params = this.buildSearchParams();
    
    this.invoices$ = this.invoiceService.getInvoices(params).pipe(
      tap(() => this.isLoading$.next(false)),
      catchError(error => {
        this.isLoading$.next(false);
        return throwError(error);
      })
    );
    
    this.cdr.detectChanges();
  }

  private buildSearchParams(): any {
    const formValue = this.searchForm.value;
    const params: any = {};

    if (formValue.search) params.search = formValue.search;
    if (formValue.status) params.status = formValue.status;
    if (formValue.fromDate) params.fromDate = formValue.fromDate;
    if (formValue.toDate) params.toDate = formValue.toDate;

    return params;
  }

  onEdit(invoiceId: number): void {
    this.router.navigate(['/invoices', invoiceId, 'edit']);
  }

  onView(invoiceId: number): void {
    this.router.navigate(['/invoices', invoiceId]);
  }

  onDelete(invoiceId: number): void {
    const dialogRef = this.dialog.open(ConfirmDialogComponent, {
      data: {
        title: 'Delete Invoice',
        message: 'Are you sure you want to delete this invoice?'
      }
    });

    dialogRef.afterClosed().pipe(
      filter(result => result),
      switchMap(() => this.invoiceService.deleteInvoice(invoiceId)),
      takeUntil(this.destroy$)
    ).subscribe(
      () => this.loadInvoices(),
      error => console.error('Failed to delete invoice', error)
    );
  }

  onCreate(): void {
    this.router.navigate(['/invoices', 'create']);
  }

  onSend(invoiceId: number): void {
    this.invoiceService.sendInvoice(invoiceId).pipe(
      takeUntil(this.destroy$)
    ).subscribe(
      () => this.loadInvoices(),
      error => console.error('Failed to send invoice', error)
    );
  }
}

Database Design

SQL Server Schema

sql
CREATE TABLE Customers (
    Id INT PRIMARY KEY IDENTITY(1,1),
    Name NVARCHAR(100) NOT NULL,
    Email NVARCHAR(100),
    Phone NVARCHAR(20),
    Address_Street NVARCHAR(100),
    Address_City NVARCHAR(50),
    Address_State NVARCHAR(50),
    Address_ZipCode NVARCHAR(20),
    Address_Country NVARCHAR(50),
    Created DATETIME2 NOT NULL,
    LastModified DATETIME2,
    CreatedBy NVARCHAR(100),
    LastModifiedBy NVARCHAR(100)
);

CREATE TABLE Products (
    Id INT PRIMARY KEY IDENTITY(1,1),
    Name NVARCHAR(100) NOT NULL,
    Description NVARCHAR(500),
    Price DECIMAL(18,2) NOT NULL,
    TaxRate DECIMAL(5,2) DEFAULT 0,
    IsActive BIT DEFAULT 1,
    Created DATETIME2 NOT NULL,
    LastModified DATETIME2,
    CreatedBy NVARCHAR(100),
    LastModifiedBy NVARCHAR(100)
);

CREATE TABLE Invoices (
    Id INT PRIMARY KEY IDENTITY(1,1),
    InvoiceNumber NVARCHAR(20) NOT NULL,
    InvoiceDate DATETIME2 NOT NULL,
    DueDate DATETIME2 NOT NULL,
    Subtotal DECIMAL(18,2) NOT NULL,
    TaxAmount DECIMAL(18,2) NOT NULL,
    DiscountAmount DECIMAL(18,2) NOT NULL,
    TotalAmount DECIMAL(18,2) NOT NULL,
    Notes NVARCHAR(500),
    Status INT NOT NULL,
    CustomerId INT NOT NULL,
    Created DATETIME2 NOT NULL,
    LastModified DATETIME2,
    CreatedBy NVARCHAR(100),
    LastModifiedBy NVARCHAR(100),
    CONSTRAINT FK_Invoices_Customers FOREIGN KEY (CustomerId) REFERENCES Customers(Id)
);

CREATE TABLE InvoiceItems (
    Id INT PRIMARY KEY IDENTITY(1,1),
    InvoiceId INT NOT NULL,
    ProductId INT NOT NULL,
    Quantity INT NOT NULL,
    UnitPrice DECIMAL(18,2) NOT NULL,
    Discount DECIMAL(5,2) DEFAULT 0,
    TaxRate DECIMAL(5,2) DEFAULT 0,
    Notes NVARCHAR(200),
    Created DATETIME2 NOT NULL,
    LastModified DATETIME2,
    CONSTRAINT FK_InvoiceItems_Invoices FOREIGN KEY (InvoiceId) REFERENCES Invoices(Id),
    CONSTRAINT FK_InvoiceItems_Products FOREIGN KEY (ProductId) REFERENCES Products(Id)
);

CREATE INDEX IX_Invoices_CustomerId ON Invoices(CustomerId);
CREATE INDEX IX_Invoices_InvoiceNumber ON Invoices(InvoiceNumber);
CREATE INDEX IX_Invoices_Status ON Invoices(Status);
CREATE INDEX IX_InvoiceItems_InvoiceId ON InvoiceItems(InvoiceId);
CREATE INDEX IX_InvoiceItems_ProductId ON InvoiceItems(ProductId);

Stored Procedures for CQRS

usp_Invoice_Create

sql
CREATE PROCEDURE [dbo].[usp_Invoice_Create]
    @InvoiceNumber NVARCHAR(20),
    @InvoiceDate DATETIME2,
    @DueDate DATETIME2,
    @CustomerId INT,
    @Notes NVARCHAR(500) = NULL,
    @ItemsJson NVARCHAR(MAX),
    @CreatedBy NVARCHAR(100),
    @InvoiceId INT OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    
    BEGIN TRY
        BEGIN TRANSACTION;
        
        -- Insert invoice header
        INSERT INTO Invoices (
            InvoiceNumber, InvoiceDate, DueDate, CustomerId, Notes,
            Subtotal, TaxAmount, DiscountAmount, TotalAmount,
            Status, Created, CreatedBy
        )
        VALUES (
            @InvoiceNumber, @InvoiceDate, @DueDate, @CustomerId, @Notes,
            0, 0, 0, 0, 0, -- Status 0 = Draft
            GETUTCDATE(), @CreatedBy
        );
        
        SET @InvoiceId = SCOPE_IDENTITY();
        
        -- Insert items and calculate totals
        DECLARE @Subtotal DECIMAL(18,2) = 0;
        DECLARE @TaxAmount DECIMAL(18,2) = 0;
        DECLARE @DiscountAmount DECIMAL(18,2) = 0;
        
        INSERT INTO InvoiceItems (
            InvoiceId, ProductId, Quantity, UnitPrice, Discount, TaxRate, Notes, Created
        )
        SELECT 
            @InvoiceId,
            ProductId,
            Quantity,
            UnitPrice,
            Discount,
            TaxRate,
            Notes,
            GETUTCDATE()
        FROM OPENJSON(@ItemsJson)
        WITH (
            ProductId INT '$.ProductId',
            Quantity INT '$.Quantity',
            UnitPrice DECIMAL(18,2) '$.UnitPrice',
            Discount DECIMAL(5,2) '$.Discount',
            TaxRate DECIMAL(5,2) '$.TaxRate',
            Notes NVARCHAR(200) '$.Notes'
        );
        
        -- Calculate totals
        SELECT 
            @Subtotal = SUM(Quantity * UnitPrice),
            @DiscountAmount = SUM(Quantity * UnitPrice * Discount / 100),
            @TaxAmount = SUM((Quantity * UnitPrice * (1 - Discount / 100)) * (TaxRate / 100))
        FROM InvoiceItems
        WHERE InvoiceId = @InvoiceId;
        
        -- Update invoice with totals
        UPDATE Invoices
        SET 
            Subtotal = @Subtotal,
            DiscountAmount = @DiscountAmount,
            TaxAmount = @TaxAmount,
            TotalAmount = @Subtotal - @DiscountAmount + @TaxAmount
        WHERE Id = @InvoiceId;
        
        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF @@TRANCOUNT > 0
            ROLLBACK TRANSACTION;
            
        THROW;
    END CATCH
END

Deployment to IIS

Prerequisites

  1. Windows Server with IIS 10+

  2. .NET Core Hosting Bundle installed

  3. URL Rewrite Module for IIS

  4. Application Pool configured for No Managed Code

Deployment Steps

  1. Publish API:

    • Right-click API project → Publish

    • Choose "Folder" target

    • Configuration: Release

    • Target Framework: net7.0

    • Deployment Mode: Framework-dependent

    • Click "Publish"

  2. Build Angular:

    bash
    ng build --configuration production
  3. IIS Setup:

    • Create new Application Pool:

      • .NET CLR version: No Managed Code

      • Managed pipeline mode: Integrated

    • Create new Website:

      • Physical path: C:\inetpub\InvoiceManagement

      • Application pool: Select the one created above

      • Bindings: Set port and hostname

  4. File Structure:

    text
    C:\inetpub\InvoiceManagement\
    ├── api\           # ASP.NET Core published files
    ├── wwwroot\       # Angular built files
    └── web.config     # For Angular
  5. web.config for API:

    xml
    <?xml version="1.0" encoding="utf-8"?>
    <configuration>
      <location path="." inheritInChildApplications="false">
        <system.webServer>
          <handlers>
            <add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
          </handlers>
          <aspNetCore processPath="dotnet" 
                      arguments=".\InvoiceManagement.API.dll" 
                      stdoutLogEnabled="true" 
                      stdoutLogFile=".\logs\stdout"
                      hostingModel="inprocess">
            <environmentVariables>
              <environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
            </environmentVariables>
          </aspNetCore>
        </system.webServer>
      </location>
    </configuration>
  6. web.config for Angular:

    xml
    <?xml version="1.0" encoding="utf-8"?>
    <configuration>
      <system.webServer>
        <rewrite>
          <rules>
            <rule name="Angular Routes" stopProcessing="true">
              <match url=".*" />
              <conditions logicalGrouping="MatchAll">
                <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
                <add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
                <add input="{REQUEST_URI}" pattern="^/(api)" negate="true" />
              </conditions>
              <action type="Rewrite" url="/" />
            </rule>
          </rules>
        </rewrite>
      </system.webServer>
    </configuration>

Performance Optimization

Database Level

  1. Indexing Strategy:

    • Create indexes on frequently queried columns

    • Consider filtered indexes for status columns

    • Use covering indexes for common query patterns

  2. Query Optimization:

    • Use stored procedures for complex operations

    • Implement proper transaction isolation levels

    • Consider read replicas for reporting queries

  3. Caching:

    • Implement Redis cache for frequently accessed data

    • Use output caching for static API responses

API Level

  1. Response Compression:

    csharp
    services.AddResponseCompression(options =>
    {
        options.Providers.Add<GzipCompressionProvider>();
        options.EnableForHttps = true;
    });
  2. Pagination:

    csharp
    public class PaginatedList<T>
    {
        public List<T> Items { get; }
        public int PageNumber { get; }
        public int TotalPages { get; }
        public int TotalCount { get; }
        
        public PaginatedList(List<T> items, int count, int pageNumber, int pageSize)
        {
            PageNumber = pageNumber;
            TotalPages = (int)Math.Ceiling(count / (double)pageSize);
            TotalCount = count;
            Items = items;
        }
        
        public bool HasPreviousPage => PageNumber > 1;
        public bool HasNextPage => PageNumber < TotalPages;
    }
  3. Async All the Way:

    • Ensure all I/O operations are async

    • Use async/await consistently through the call stack

Angular Level

  1. Change Detection:

    • Use OnPush change detection strategy

    • Leverage async pipe in templates

    • Avoid unnecessary change detection cycles

  2. Lazy Loading:

    typescript
    const routes: Routes = [
      {
        path: 'invoices',
        loadChildren: () => import('./modules/invoices/invoices.module').then(m => m.InvoicesModule)
      }
    ];
  3. Performance Budgets:

    • Set angular.json performance budgets

    • Monitor bundle sizes

Security Considerations

API Security

  1. Authentication:

    • JWT with refresh tokens

    • Secure cookie settings

    • Short-lived access tokens

  2. Authorization:

    • Role-based and policy-based authorization

    • Resource-based authorization for sensitive operations

  3. Input Validation:

    • Validate all inputs with FluentValidation

    • Sanitize HTML inputs

    • Use parameterized queries

  4. HTTPS Enforcement:

    csharp
    services.AddHttpsRedirection(options =>
    {
        options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect;
        options.HttpsPort = 443;
    });
  5. CORS Policy:

    csharp
    services.AddCors(options =>
    {
        options.AddPolicy("CorsPolicy", policy => 
        {
            policy.WithOrigins(configuration["AllowedOrigins"])
                .AllowAnyHeader()
                .AllowAnyMethod()
                .AllowCredentials();
        });
    });

Angular Security

  1. XSS Protection:

    • Sanitize all user inputs

    • Use DomSanitizer for dynamic content

    • Implement Content Security Policy (CSP)

  2. Auth Guard:

    typescript
    @Injectable({
      providedIn: 'root'
    })
    export class AuthGuard implements CanActivate {
      constructor(private authService: AuthService, private router: Router) {}
      
      canActivate(
        route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): Observable<boolean | UrlTree> {
        return this.authService.currentUser$.pipe(
          map(user => {
            if (user) {
              return true;
            }
            
            return this.router.createUrlTree(['/login'], {
              queryParams: { returnUrl: state.url }
            });
          })
        );
      }
    }
  3. HTTP Security Headers:

    • Configure in web.config or at server level

    • Include X-Content-Type-Options, X-Frame-Options, etc.

Testing Strategy

Unit Testing

Command Handler Test

csharp
public class CreateInvoiceCommandHandlerTests
{
    private readonly Mock<IApplicationDbContext> _mockContext;
    private readonly CreateInvoiceCommandHandler _handler;
    
    public CreateInvoiceCommandHandlerTests()
    {
        _mockContext = new Mock<IApplicationDbContext>();
        _mockContext.Setup(c => c.Invoices).Returns(MockDbSet<Invoice>());
        _mockContext.Setup(c => c.InvoiceItems).Returns(MockDbSet<InvoiceItem>());
        
        var mapper = new MapperConfiguration(cfg => 
            cfg.AddProfile<MappingProfile>()).CreateMapper();
            
        _handler = new CreateInvoiceCommandHandler(_mockContext.Object, mapper);
    }
    
    [Fact]
    public async Task Handle_ValidCommand_ShouldCreateInvoice()
    {
        // Arrange
        var command = new CreateInvoiceCommand
        {
            InvoiceNumber = "INV-001",
            InvoiceDate = DateTime.Today,
            DueDate = DateTime.Today.AddDays(30),
            CustomerId = 1,
            Items = new List<InvoiceItemDto>
            {
                new InvoiceItemDto
                {
                    ProductId = 1,
                    Quantity = 2,
                    UnitPrice = 10,
                    Discount = 0,
                    TaxRate = 10
                }
            }
        };
        
        // Act
        var result = await _handler.Handle(command, CancellationToken.None);
        
        // Assert
        result.Should().BeGreaterThan(0);
        _mockContext.Verify(m => m.Invoices.Add(It.IsAny<Invoice>()), Times.Once);
        _mockContext.Verify(m => m.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
    }
    
    private static Mock<DbSet<T>> MockDbSet<T>() where T : class
    {
        var mockSet = new Mock<DbSet<T>>();
        var data = new List<T>().AsQueryable();
        
        mockSet.As<IQueryable<T>>().Setup(m => m.Provider).Returns(data.Provider);
        mockSet.As<IQueryable<T>>().Setup(m => m.Expression).Returns(data.Expression);
        mockSet.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(data.ElementType);
        mockSet.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(data.GetEnumerator());
        
        return mockSet;
    }
}

Integration Testing

InvoicesControllerTests.cs

csharp
public class InvoicesControllerTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;
    
    public InvoicesControllerTests(CustomWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }
    
    [Fact]
    public async Task Get_ReturnsInvoicesList()
    {
        // Act
        var response = await _client.GetAsync("/api/invoices");
        
        // Assert
        response.EnsureSuccessStatusCode();
        var responseString = await response.Content.ReadAsStringAsync();
        var invoices = JsonSerializer.Deserialize<List<InvoiceListDto>>(responseString);
        
        invoices.Should().NotBeEmpty();
    }
    
    [Fact]
    public async Task Post_ValidInvoice_ReturnsCreated()
    {
        // Arrange
        var command = new CreateInvoiceCommand
        {
            InvoiceNumber = "INV-TEST",
            InvoiceDate = DateTime.Today,
            DueDate = DateTime.Today.AddDays(30),
            CustomerId = 1,
            Items = new List<InvoiceItemDto>
            {
                new InvoiceItemDto
                {
                    ProductId = 1,
                    Quantity = 1,
                    UnitPrice = 100,
                    Discount = 0,
                    TaxRate = 10
                }
            }
        };
        
        var content = new StringContent(
            JsonSerializer.Serialize(command),
            Encoding.UTF8,
            "application/json");
        
        // Act
        var response = await _client.PostAsync("/api/invoices", content);
        
        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);
    }
}

Angular Testing

invoice.service.spec.ts

typescript
describe('InvoiceService', () => {
  let service: InvoiceService;
  let httpTestingController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [InvoiceService]
    });
    
    service = TestBed.inject(InvoiceService);
    httpTestingController = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpTestingController.verify();
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should get invoices', () => {
    const mockInvoices: InvoiceListDto[] = [
      { id: 1, invoiceNumber: 'INV-001', date: '2023-01-01', dueDate: '2023-01-31', totalAmount: 100, status: 'Draft', customerName: 'Test Customer' }
    ];
    
    service.getInvoices().subscribe(invoices => {
      expect(invoices).toEqual(mockInvoices);
    });
    
    const req = httpTestingController.expectOne(`${environment.apiUrl}/invoices`);
    expect(req.request.method).toEqual('GET');
    req.flush(mockInvoices);
  });

  it('should create invoice', () => {
    const mockInvoice: CreateInvoiceDto = {
      invoiceNumber: 'INV-001',
      invoiceDate: '2023-01-01',
      dueDate: '2023-01-31',
      customerId: 1,
      items: [
        { productId: 1, quantity: 1, unitPrice: 100, discount: 0, taxRate: 10, notes: '' }
      ]
    };
    
    service.createInvoice(mockInvoice).subscribe(id => {
      expect(id).toEqual(1);
    });
    
    const req = httpTestingController.expectOne(`${environment.apiUrl}/invoices`);
    expect(req.request.method).toEqual('POST');
    req.flush(1);
  });
});

Monitoring and Logging

Serilog Configuration

Program.cs

csharp
builder.Host.UseSerilog((context, services, configuration) => 
    configuration
        .ReadFrom.Configuration(context.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext()
        .WriteTo.Console()
        .WriteTo.File(
            path: Path.Combine("Logs", "log-.txt"),
            rollingInterval: RollingInterval.Day,
            retainedFileCountLimit: 7)
        .WriteTo.Seq(serverUrl: context.Configuration["Seq:ServerUrl"])
);

Health Checks

csharp
services.AddHealthChecks()
    .AddSqlServer(
        connectionString: configuration.GetConnectionString("DefaultConnection"),
        name: "sqlserver",
        tags: new[] { "db", "sqlserver" })
    .AddDbContextCheck<ApplicationDbContext>(
        name: "dbcontext",
        tags: new[] { "db", "efcore" });
        
app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

Application Insights

csharp
builder.Services.AddApplicationInsightsTelemetry(options =>
{
    options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
    options.EnableAdaptiveSampling = false;
});

builder.Services.AddApplicationInsightsTelemetryProcessor<FilterHealthChecksTelemetryProcessor>();

Conclusion

This comprehensive guide has demonstrated how to build a robust Invoice Management System using:

  1. CQRS Pattern: Separating read and write operations for better scalability

  2. Clean Architecture: Clear separation of concerns across layers

  3. Domain-Driven Design: Focusing on business logic in the domain layer

  4. ASP.NET Core Web API: Building a secure, performant backend

  5. Angular: Creating a responsive, modern frontend

  6. SQL Server: Relational database with optimized queries

  7. IIS Deployment: Production-ready hosting configuration

The system includes:

  • Complete invoice CRUD operations

  • Business rules and validation

  • JWT authentication

  • Error handling and logging

  • Performance optimizations

  • Security best practices

  • Testing strategies

This architecture provides a solid foundation that can be extended with additional features like:

  • PDF invoice generation

  • Email notifications

  • Payment processing integration

  • Advanced reporting

  • Multi-tenancy support

The CQRS approach offers particular benefits for invoice management systems where read and write workloads often have different performance characteristics and requirements.







0 comments:

 
Toggle Footer