Skip to main content

System Overview

The Dash360 export system is a serverless, multi-tenant, high-performance architecture that replaces the monolithic ImportExportController with a scalable Lambda-based solution.

87% Code Reduction

Consolidated base classes eliminate duplication across all export functions

Sub-second Performance

Fire-and-forget progress updates keep data processing at full speed

Multi-Tenant Ready

Domain-based tenant resolution with AWS Parameter Store

Infinitely Scalable

Data-driven Terraform modules — add a new export in 7 lines of HCL

Architecture Diagram

┌─────────────────┐    ┌─────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Web App       │    │ SQS Queue   │    │  Export Router   │    │ Export Lambda   │
│ (Controller)    │───▶│ (Single)    │───▶│   (Router)       │───▶│ (Type-Specific) │
└─────────────────┘    └─────────────┘    └──────────────────┘    └─────────────────┘
         │                                          │                        │
         ▼                                          │                        ▼
┌─────────────────┐                                 │               ┌─────────────────┐
│   DynamoDB      │                                 │               │   S3 Storage    │
│ (Job Tracking)  │◀────────────────────────────────┘               │  (Excel Files)  │
└─────────────────┘                                                 └─────────────────┘
         │                                                                   │
         ▼                                                                   │
┌─────────────────┐    ┌──────────────────┐    ┌─────────────┐             │
│   EventBridge   │───▶│    SignalR       │───▶│Notification │◀────────────┘
│   (Progress)    │    │   (Real-time)    │    │    Bell     │
└─────────────────┘    └──────────────────┘    └─────────────┘

Router Pattern

Why Router Pattern?

Problem Solved: Multiple Lambda functions listening to the same SQS queue with message filters caused unreliable delivery — 3–4 retry attempts were needed.
Solution: A single Export Router Lambda receives ALL export messages and routes them to specific Lambda functions using async Lambda.Invoke.
1. Web App publishes message to SQS queue
2. Export Router receives message (single ESM, no filters)
3. Router examines dataType and routes to appropriate Lambda
4. Target Lambda processes export asynchronously
5. No competing ESMs = 100% reliable delivery
Benefits of the router pattern:
  • 100% message reliability — no lost messages
  • Scales to 45+ export types — just add an entry to the router mapping
  • No LocalStack timing issues — single Event Source Mapping
  • Easy to monitor — all routing logic in one place

Consolidated Lambda Functions

Before: Massive Code Duplication

RiskCategoriesFunction.cs  — 384 lines
RiskTypesFunction.cs       — 415 lines
PremiumPayFunction.cs      — 396 lines
TOTAL: 1,195 lines with 95% duplication

After: Consolidated Pattern

BaseImportExportLambdaFunction<TEntity, TDto>  — 300 lines (shared)
RiskCategoriesExportFunction.cs                —  49 lines (unique logic only)
RiskTypesExportFunction.cs                     —  46 lines (unique logic only)
PremiumPayExportFunction.cs                    —  62 lines (unique logic only)
TOTAL: 160 lines (87% reduction)

Implementation Pattern

Each export Lambda implements only 5 abstract methods:
public class HolidaysExportFunction : BaseImportExportLambdaFunction<Holiday, HolidayDto>
{
    protected override string DataTypeName => "Holidays";
    protected override string TableName => "Holiday";

    protected override async Task<List<Holiday>> QueryEntitiesAsync(LambdaDbContext context, int? projectId)
    {
        // Unique database query logic (with optional project filtering)
        return await context.Holidays.ToListAsync();
    }

    protected override HolidayDto MapToDto(Holiday entity)
    {
        // Unique entity → DTO mapping
        return new HolidayDto { ID = entity.ID, Name = entity.Name, Delete = "No" };
    }

    protected override ExcelColumnDefinition<HolidayDto>[] GetColumnDefinitions() => new[]
    {
        new ExcelColumnDefinition<HolidayDto> { Header = "ID", ValueSelector = d => d.ID },
        new ExcelColumnDefinition<HolidayDto> { Header = "Holiday Name", ValueSelector = d => d.Name },
        new ExcelColumnDefinition<HolidayDto> { Header = "Delete", ValueSelector = d => d.Delete },
    };
}

What You Get for Free

All export Lambdas automatically inherit:
  • SQS event handling (HandleSqsAsync)
  • Progress tracking (fire-and-forget DynamoDB updates)
  • S3 file storage (tenant-organized folder structure)
  • EventBridge notifications (real-time progress)
  • Error handling (comprehensive exception management)
  • Parameter Store integration (tenant connection strings)

Multi-Tenant Architecture

Domain-Based Tenant Resolution

The system automatically detects the tenant from the request domain:
DomainTenant
localhosttestcustomer (development)
lccf.dash360.comlccf
frib.dash360.comfrib
dash360.skao.intskao

Parameter Store Hierarchy

/dash360/localstack/tenants/testcustomer/connectionString
/dash360/dev/tenants/lccf/connectionString
/dash360/production/tenants/frib/connectionString
User isolation: Each user only sees their own export notifications, even within the same tenant.

Performance Optimizations

Fire-and-Forget Progress Updates

// Old approach (blocking — bottleneck for large exports)
await UpdateProgressAsync(jobId, processed, total, message);

// New approach (non-blocking — data processing at full speed)
_ = Task.Run(async () => await UpdateProgressAsync(jobId, processed, total, message));
Impact: 1,200 records went from ~30 seconds to ~2 seconds.

Frontend Progress Protection

// Prevents progress bars from going backwards due to async update ordering
if (progress.progressPercent >= currentPercent) {
    updateProgressBar(progress.progressPercent);
}

Batch Processing

  • Progress updates every 100 records (not every 5)
  • 95% fewer DynamoDB calls for large datasets
  • Parallel progress updates don’t block data processing

Adding a New Export Type

1

Create Lambda Function

// Dash360.Lambda/Functions/Holidays/HolidaysExportFunction.cs
public class HolidaysExportFunction : BaseImportExportLambdaFunction<Holiday, HolidayDto>
{
    // Implement 5 abstract methods (15-20 lines total)
}
2

Create DTO

// Dash360.Shared/DTOs/HolidayDto.cs
public class HolidayDto
{
    public int ID { get; set; }
    public string Name { get; set; } = "";
    public string Delete { get; set; } = "No";
}
3

Add to Entity Framework

// Dash360.Lambda.SharedLayer/Data/LambdaDbContext.cs
public DbSet<Holiday> Holidays { get; set; } = null!;
4

Add to Infrastructure

# main.tf — Add to export_functions map
"Holidays" = {
  data_type      = "Holidays"
  lambda_handler = "Dash360.Lambda::...HolidaysExportFunction::HandleSqsAsync"
  timeout        = 300
  memory_size    = 512
}
5

Add to Router

// ExportRouterFunction.cs
"Holidays" => $"{functionPrefix}-Holidays",
6

Add to Web App

// LambdaExportService.cs
public async Task<ExportJobResult> StartHolidaysExportAsync(...)

// LambdaExportController.cs
public async Task<IActionResult> ExportHolidays()
Total effort: ~2–3 hours for a complete new export type.

Infrastructure Patterns

Data-Driven Module Approach

Instead of 45+ individual Terraform module calls, a single module creates all export Lambdas:
module "export_lambdas" {
  source = "../../modules/lambda-export-functions"

  export_functions = {
    "RiskCategories" = {
      data_type   = "Risk Categories"
      lambda_handler = "...RiskCategoriesExportFunction::HandleSqsAsync"
      memory_size = 512
    },
    "PremiumPay" = {
      data_type   = "Premium Pay"
      lambda_handler = "...PremiumPayExportFunction::HandleSqsAsync"
      memory_size = 1024  # More memory for large datasets with filtering
    }
    # Each new export = 7 lines here
  }
}

Architecture Decisions

Decision: Use Router Lambda instead of SQS message attribute filters.Reasoning: SQS filters with multiple Event Source Mappings caused message competition and delivery failures in testing. AWS documentation confirms this as an anti-pattern for this use case.
Decision: Use BaseImportExportLambdaFunction with inheritance.Reasoning: 95% code duplication across export functions. A single base class reduces maintenance burden and ensures consistent error handling and progress tracking across all exports.
Decision: Use Parameter Store for tenant connection strings.Reasoning: Connection strings are configuration, not secrets in the security sense. Parameter Store is cost-effective, supports hierarchical naming, and fits the multi-tenant pattern well.
Decision: Non-blocking progress updates with frontend protection against out-of-order delivery.Reasoning: Progress updates shouldn’t block data processing. 95% performance improvement for large datasets. Frontend already protects against the only side effect (bars going backwards).

Security

LayerIsolation Mechanism
DatabaseSeparate connection string per tenant via Parameter Store
S3 StorageTenant-specific folder structure ({tenant}/exports/)
NotificationsExport events scoped to individual user IDs
IAMLeast-privilege Lambda execution roles
Parameter Store security: LocalStack uses String parameters; production uses SecureString with KMS encryption.

Monitoring and Observability

SignalPurpose
CloudWatch Logs (Router)Message routing decisions and failures
CloudWatch Logs (Export)Processing details, record counts, timing
DynamoDB (dash360-processing-jobs-{env})Export progress, completion status, download URLs
EventBridge (dash360.lambda)Real-time progress events to web app via SignalR

Performance Benchmarks (LocalStack)

Export TypeRecordsDuration
Risk Categories50< 1 second
Risk Types45< 1 second
Premium Pay1,201~2 seconds

Troubleshooting

  • Check Router Lambda logs — is the message being routed correctly?
  • Check target Lambda logs — is the target Lambda starting?
  • Check for build errors — LambdaSerializer attribute issues prevent cold start
This is expected behavior with fire-and-forget updates. The frontend only moves progress bars forward. Final completion is always awaited, so the export still completes correctly.
  • LocalStack: Verify SSM endpoint is configured in the Terraform provider
  • Permissions: Confirm Lambda execution role has GetParameter permission
  • Fallback: System logs the error and continues with fallback connection strings
  1. Run terraform init after adding new modules
  2. Verify relative module paths are correct
  3. Run terraform validate before apply