ASP.NET Core with Angular Invoice Management System using CQRS Pattern
Table of Contents
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
Scalability: Read and write workloads can scale independently
Optimization: Queries and commands can be optimized separately
Simplified Models: Each side can focus on its specific responsibility
Flexibility: Easier to evolve the system over time
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:
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
InvoiceManagement.API: ASP.NET Core Web API project
InvoiceManagement.Application: CQRS handlers, DTOs, interfaces
InvoiceManagement.Domain: Core domain models and business rules
InvoiceManagement.Infrastructure: Data access, identity, external services
InvoiceManagement.Client: Angular frontend (optional separate project)
Key NuGet Packages
<!-- 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
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
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
namespace InvoiceManagement.Domain.Events { public class InvoiceCreatedEvent : DomainEvent { public Invoice Invoice { get; } public InvoiceCreatedEvent(Invoice invoice) { Invoice = invoice; } } }
Application Layer (CQRS)
Commands
CreateInvoiceCommand.cs
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
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
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
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
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
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
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
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
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
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
@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
@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
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
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
Windows Server with IIS 10+
.NET Core Hosting Bundle installed
URL Rewrite Module for IIS
Application Pool configured for No Managed Code
Deployment Steps
Publish API:
Right-click API project → Publish
Choose "Folder" target
Configuration: Release
Target Framework: net7.0
Deployment Mode: Framework-dependent
Click "Publish"
Build Angular:
ng build --configuration production
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
File Structure:
C:\inetpub\InvoiceManagement\ ├── api\ # ASP.NET Core published files ├── wwwroot\ # Angular built files └── web.config # For Angular
web.config for API:
<?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>
web.config for Angular:
<?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
Indexing Strategy:
Create indexes on frequently queried columns
Consider filtered indexes for status columns
Use covering indexes for common query patterns
Query Optimization:
Use stored procedures for complex operations
Implement proper transaction isolation levels
Consider read replicas for reporting queries
Caching:
Implement Redis cache for frequently accessed data
Use output caching for static API responses
API Level
Response Compression:
services.AddResponseCompression(options => { options.Providers.Add<GzipCompressionProvider>(); options.EnableForHttps = true; });
Pagination:
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; }
Async All the Way:
Ensure all I/O operations are async
Use async/await consistently through the call stack
Angular Level
Change Detection:
Use OnPush change detection strategy
Leverage async pipe in templates
Avoid unnecessary change detection cycles
Lazy Loading:
const routes: Routes = [ { path: 'invoices', loadChildren: () => import('./modules/invoices/invoices.module').then(m => m.InvoicesModule) } ];
Performance Budgets:
Set angular.json performance budgets
Monitor bundle sizes
Security Considerations
API Security
Authentication:
JWT with refresh tokens
Secure cookie settings
Short-lived access tokens
Authorization:
Role-based and policy-based authorization
Resource-based authorization for sensitive operations
Input Validation:
Validate all inputs with FluentValidation
Sanitize HTML inputs
Use parameterized queries
HTTPS Enforcement:
services.AddHttpsRedirection(options => { options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect; options.HttpsPort = 443; });
CORS Policy:
services.AddCors(options => { options.AddPolicy("CorsPolicy", policy => { policy.WithOrigins(configuration["AllowedOrigins"]) .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); }); });
Angular Security
XSS Protection:
Sanitize all user inputs
Use DomSanitizer for dynamic content
Implement Content Security Policy (CSP)
Auth Guard:
@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 } }); }) ); } }
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
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
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
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
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
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
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:
CQRS Pattern: Separating read and write operations for better scalability
Clean Architecture: Clear separation of concerns across layers
Domain-Driven Design: Focusing on business logic in the domain layer
ASP.NET Core Web API: Building a secure, performant backend
Angular: Creating a responsive, modern frontend
SQL Server: Relational database with optimized queries
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:
Post a Comment