Crowdlinks project
Crowdlinks Backend
Work

Crowdlinks Backend

Apr 2021
Table of Contents

Overview

The CrowdLinks backend is a Modular Monolith application built with Kotlin and Spring Boot, designed to handle complex business logic through Domain-Driven Design (DDD) principles.

Starting from a prototype phase, we needed a robust architecture that could support rapid iteration while maintaining a clean separation of concerns. We chose a Modular Monolith approach over Microservices to balance developer productivity with architectural boundaries, allowing a small team to move fast without the operational overhead of distributed systems.

The system implements Hexagonal Architecture (Ports and Adapters) to isolate the core domain from external infrastructure, and utilizes CQRS and Event-Driven patterns to handle complex data flows and ensure scalability.

Backend Overview Diagram

Tech Stack

TechnologyCategoryDescription
KotlinLanguageChosen for its null safety, conciseness, and seamless interoperability with Java/Spring.
Spring BootFrameworkThe backbone of the application, providing dependency injection, web MVC, and transaction management.
MySQL 8.0DatabasePrimary relational database.
jOOQORM / SQLUsed for type-safe SQL construction. We prefer jOOQ over Hibernate for better control over SQL and easier mapping to DDD concepts.
FlywayMigrationManages database schema changes with versioned SQL scripts.
CircleCICI/CDAutomates testing and deployment. Part of a polyglot monorepo pipeline.
AlgoliaSearchPowered the search functionality (synced via domain events).

Core Design Patterns / Principles

ItemSummaryReason for Adoption
DDD (Domain Driven Design)Focuses on complex domain models by connecting implementation to business processes. Uses ubiquitous language to bridge communication between technical and domain experts.Ideal for complex business domains where clear domain modeling is crucial. Improves collaboration between developers and business stakeholders.
Hexagonal ArchitecturePorts-and-adapters pattern that isolates core business logic from external concerns (UI, databases, etc.). Uses defined interfaces to connect with external systems.Enhances testability by allowing core logic to be tested without dependencies. Improves maintainability and flexibility when replacing external systems.
Modular MonolithSingle deployable unit organized into loosely coupled modules. Modules have clear boundaries and explicit interfaces.Simplifies deployment compared to microservices. Reduces operational complexity while maintaining modularity. Good for applications with moderate complexity.
(In-memory) Event-DrivenArchitecture where components communicate through asynchronous events. Decouples producers and consumers of events.Enables loose coupling between system components. Supports scalability and resilience through asynchronous processing. Ideal for distributed systems.
CQRS (Command Query Responsibility Segregation)Separates read (query) and write (command) operations into different models. Often combined with event sourcing.Optimizes performance by using specialized models for different operations. Simplifies complex business logic by separating concerns. Improves scalability for read-heavy systems.

Core Package Structure

The backend package is divided into four main parts:

Event-Driven and PubSub

Communication between loosely coupled contexts is realized via Domain Events.

When a domain event with a specific topic is published within one context, subscribers in other contexts that have registered interest in that topic are triggered to execute arbitrary processing. For implementation convenience, CrowdLinks uses Spring Boot’s DI container for this PubSub mechanism.

Hexagonal Architecture

Hexagonal Architecture Diagram

We adopt Hexagonal Architecture, dividing each context into three layers from the core outward:

  1. Domain Layer: Implements domain objects.
  2. Use Case Layer: Implements query handlers and command handlers.
  3. Port Layer: Implements elements for accessing external systems (Web, Database, other contexts).

Dependencies flow only from outer layers to inner layers; the reverse is strictly forbidden.

CQRS

We adopt the CQRS pattern, separating read and write operations:

In the database, read tables and write tables are separated, with data synchronization realized by the event-driven pattern.

Why Chose Modular Monolith Over Microservice?

Modular Monolith vs Microservice

Modular Monolith vs Microservice

After our own research and consulting with other companies known for their DDD implementation, we received the opinion that “whether domain and transaction boundaries are clear or not” is a major deciding factor for choosing between microservices or monolithic architecture.

Following this decision criterion, at that time, CrowdLinks was still a relatively new service with daily changes in domain knowledge, requiring trial and error from a DDD perspective. The domain and transaction boundaries were unstable and not clearly defined.

Building on this foundation, first, in terms of product scale, it was a codebase where we believed developer experience (DX) would not decrease even with a monolithic approach. Additionally, when considering a scale that could be developed by a 3-5 person engineering team, we thought that dividing the application into microservices would likely overwhelm our capacity.

Therefore, although we initially lacked this decision framework, we were ultimately able to make the decision with considerable confidence to start with a monolithic approach.

Domain-Driven Design (DDD)

Domain-Driven Design (DDD) is an approach that focuses on the business domain, translating complex business specifications into system design. By placing business rules at the center of the system, we prevent the dispersion of business logic and achieve highly maintainable development.

What DDD Solves

Key Elements

ElementDescription
Bounded Context• Defines the boundary within which a specific term holds a specific meaning.
• Words change meaning depending on context (e.g., “Money” is in-game currency in Game Context vs. real currency in Payment Context).
• Defining the context is crucial to determine the meaning of the language used.
Domain Objects• The core elements expressing business logic and the center of the system.
• Includes Entities, Value Objects, and Aggregates.
• Properly placing business rules here maintains system integrity.
Entities• Objects defined by their identity and lifecycle.
• Unique within the system.
• Identity persists even if attributes change (e.g., a User remains the same entity even if their address changes).
Value Objects• Objects defined by their attributes rather than identity.
• No identity check; equality is based on values.
• Example: Two instances of the name “John Doe” are considered the same value, even if they are distinct objects.
Aggregates• A cluster of domain objects grouped to ensure consistency.
• Changes must go through the Aggregate Root to guarantee rules.
• Defining aggregates is effectively defining transaction boundaries.
Example: The Project aggregate includes Project Title. Changing the title goes through the Project root, ensuring consistency with other states (like “Draft” or “Published”) in the same transaction.

Deep Dive: Aggregates & Consistency

To illustrate how we apply DDD principles in practice, let’s look at how we handle data consistency and transactions using Aggregates.

1. Consistency via Aggregate Roots

In CrowdLinks, we strictly enforce the rule that changes to entities within an aggregate must only occur through the Aggregate Root. This ensures that business invariants are always maintained.

Example: Project Aggregate

The Project entity acts as the Aggregate Root. It manages its internal state (like status and content) and ensures that valid transitions occur.

fun changeStatusTo(status: ProjectStatusType, projectContent: ProjectContent): Project {
    // 1. Validation: Check if the status transition is valid based on current state and content
    require(this.isChangeableStatus(status, projectContent))

    // 2. State Change: Return a new instance with the updated status (Immutability)
    // We never mutate the existing instance; instead, we return a fresh copy to ensure thread safety and predictability.
    return copy(
        status = status,
        publishedDatetime = if (this.publishedDatetime == null && status === ProjectStatusType.PUBLISHED) {
            DateTimeProvider.now()
        } else {
            null
        },

        // 3. Event Driven: Register a domain event to notify other parts of the system (e.g., Search Indexer)
        events = this.events.occur(
            ProjectStatusChangedEvent(
                projectId = this.id,
                universalCorporationId = this.universalCorporationId,
                statusBeforeChange = this.status,
                statusAfterChange = status
            )
        )
    )
}boundedContext/project/domain/.../Project.kt

And here is how a Use Case utilizes this Aggregate to perform a transaction:

@Service
class ChangeProjectStatusUseCase(
    @Autowired private val projectRepository: ProjectRepository,
    @Autowired private val projectContentFactory: ProjectContentFactory,
    @Autowired private val domainEventPublisher: DomainEventPublisher
) : CommandUseCase<ChangeProjectStatusCommand, Unit> {
    @Transactional
    override fun handle(command: ChangeProjectStatusCommand) {
        // 1. Reconstitute: Load the aggregate from the repository
        val project = projectRepository.findUnarchivedOneById(command.projectId).getOrFail()
        val projectContent = projectContentFactory.create(project)

        // 2. Business Logic: Delegate state change to the Aggregate Root
        // The Use Case doesn't know *how* to change status, only *that* it should.
        // The Aggregate is immutable, so we receive a new instance with the updated state.
        val changedProject = project.changeStatusTo(command.status, projectContent)

        // 3. Persist: Save the updated aggregate
        projectRepository.store(changedProject)

        // 4. Publish Events: Propagate side effects
        domainEventPublisher.publishAll(changedProject.getAllOccurredEvents())
    }
}/.../ChangeProjectStatusUseCase.kt

By forcing all status changes through changeStatusTo, we guarantee that a Project can never be in an invalid state (e.g., “Published” without required content).

2. Domain Services (Multi-Aggregate Transactions)

When business logic spans multiple aggregates but doesn’t necessarily involve a state change transaction, we use Domain Services.

The ScoutAcceptanceService coordinates the acceptance of a scout, which involves updating the Scout aggregate and adding the user to the Project aggregate. This encapsulates the complexity of interacting with multiple aggregates to perform a specific domain calculation, keeping the Use Cases clean.

@Service
class ScoutAcceptanceService(
    private val scoutRepository: ScoutRepository,
    private val projectRepository: ProjectRepository
) {
    @Transactional
    fun acceptScout(scoutId: ScoutId) {
        // 1. Load Scout Aggregate
        val scout = scoutRepository.findById(scoutId)
            ?: throw NotFoundException("Scout not found")

        // 2. Update Scout status
        scout.accept()
        scoutRepository.save(scout)

        // 3. Load Project Aggregate
        val project = projectRepository.findById(scout.projectId)
            ?: throw NotFoundException("Project not found")

        // 4. Update Project (Cross-Aggregate Business Rule)
        // When a scout is accepted, the user automatically becomes a candidate.
        project.addCandidate(scout.userId)
        projectRepository.save(project)
    }
}boundedContext/.../ScoutAcceptanceService.kt

But, why not use Domain Events?

While we could use an event-driven approach (e.g., ScoutAcceptedEvent -> ProjectCandidateListener), we chose a synchronous transaction to explicitly express the domain knowledge: “A user must be a candidate if they accept a scout.” By encapsulating this rule within a Domain Service, the code itself becomes a direct representation of this business requirement, rather than scattering the logic across decoupled event listeners.

Cross-Cutting Concerns

Technical concerns that span multiple modules are isolated in a dedicated crossCuttingConcern package. This prevents “technical noise” from polluting the pure domain logic.

Package Structure:

jp.crowdlinks.crossCuttingConcern
├── errorHandling
├── logging
├── security
├── pubsub
└── ...

Logging (AOP)

We use Aspect-Oriented Programming (AOP) to handle concerns like audit logging transparently. For instance, TrackEntityChangeAop automatically records changes to entities without requiring manual logging calls in every Use Case.

@Aspect
@Component
class TrackEntityChangeAop(@Autowired private val entityChangeRepository: EntityChangeRepository) {
    /**
     * Intercepts methods annotated with @TrackEntityChange to record the change.
     */
    @Before(value = "@annotation(${TrackEntityChange.PACKAGE_NAME}) && args(entity)")
    fun record(joinPoint: JoinPoint, entity: Entity<*>) {
        val methodName = joinPoint.signature.name
        val entityChange = EntityChange.create(
            requestId = MDCValueManager.getRequestId(),
            methodName = methodName,
            target = entity
        )
        entityChangeRepository.store(entityChange)
    }
}/.../TrackEntityChangeAop.kt

Error Handling

We use @RestControllerAdvice to centralize exception handling. This ensures a consistent error response format across the entire API, mapping domain exceptions (like AggregateRootNotFound) to appropriate HTTP status codes (like 404 Not Found).

Critical errors are reported to Rollbar via an ExceptionNotifier interface, which abstracts the external service.

@Component
class RollbarExceptionNotifier(
    @Autowired private val properties: ApplicationProperties,
    @Autowired private val request: HttpServletRequest
) : ExceptionNotifier {

    override fun error(exception: Exception) {
        client().error(exception)
    }

    private fun client(): Rollbar {
        val config = ConfigBuilder.withAccessToken(properties.rollbar.accessToken)
            .environment(properties.rollbar.environment)
            .request(RequestProvider(request)) // Attach request context (URL, IP, etc.)
            .build()
        return Rollbar(config)
    }
}/.../RollbarExceptionNotifier.kt

And the global handler uses this notifier:

@RestControllerAdvice
class RestControllerExceptionHandler(@Autowired private val exceptionNotifier: ExceptionNotifier) {

    @ExceptionHandler(CustomException.AggregateRootNotFound::class)
    fun handleNotFound(
        exception: CustomException.AggregateRootNotFound
    ): ResponseEntity<NotFoundResponse> {
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(NotFoundResponse(message = exception.message))
    }

    @ExceptionHandler(Exception::class)
    fun handleInternalServerError(exception: Exception): ResponseEntity<InternalServerErrorResponse> {
        // Notify Rollbar
        exceptionNotifier.error(exception)

        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(InternalServerErrorResponse())
    }

    // ... other exception handlers
}/.../RestControllerExceptionHandler.kt

Security

Security configurations, such as authentication filters and CORS settings, are managed centrally using Spring Security. We implement custom filters (e.g., JsonWebTokenParseFilter) to handle JWT authentication before requests reach the domain logic.

@Configuration
@EnableWebSecurity
class InternalFrontendWebSecurityConfig : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http
            .authorizeRequests()
            .antMatchers("/health_check").permitAll()
            .anyRequest().authenticated()
            .and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .addFilterBefore(jsonWebTokenParseFilter, UsernamePasswordAuthenticationFilter::class.java)
    }
}/.../InternalFrontendWebSecurityConfig.kt

Challenges & Learnings

The Good

The Struggle

Related Projects

    Mike 3.0

    Send a message to start the chat!

    You can ask the bot anything about me and it will help to find the relevant information!

    Try asking: