Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/gradle-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a package using Gradle and then publish it to GitHub packages when a release is created
# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#Publishing-using-gradle

name: Gradle Build

on:
workflow_dispatch

jobs:
build:

runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@v4
- name: Set up JDK 25
uses: actions/setup-java@v4
with:
java-version: '25-ea'
distribution: 'temurin'
server-id: github # Value of the distributionManagement/repository/id field of the pom.xml
settings-path: ${{ github.workspace }} # location for the settings.xml file

- name: Setup Gradle
uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0

- name: Build with Gradle
run: ./gradlew build
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
# fun-vs-framework
Demo project for my talk: Fucntional Principles vs the Framework
[![Gradle Build](https://github.com/tibtof/fun-vs-framework/actions/workflows/gradle-build.yml/badge.svg)](https://github.com/tibtof/fun-vs-framework/actions/workflows/gradle-build.yml)

# Own your design: functional principles vs the framework

Demo project to showcase the refactoring of a Spring Boot application from a layered architecture to a hexagonal architecture. The _main_ branch contains the refactored solution. There are also individual branches for each architectural approach: [layered](https://github.com/tibtof/fun-vs-framework/tree/layered) and [hexagonal](https://github.com/tibtof/fun-vs-framework/tree/hexagonal), with a [PR that highlights the changes](https://github.com/tibtof/fun-vs-framework/pull/2).

To better understand the context I recomend [watching my talk](https://www.youtube.com/watch?v=kqNDeq-DrVM) and going over the slides.
46 changes: 28 additions & 18 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,45 +1,55 @@
val springCloudVersion: String by project
val springKafkaVersion: String by project
val postgresqlVersion: String by project
val wiremockTestcontainersVersion: String by project
val junitPlatformLauncherVersion: String by project
val mockitoCoreVersion: String by project
val archunitVersion: String by project
val openFeignVersion: String by project
val springBootTestcontainersVersion: String by project
val testcontainersVersion: String by project

plugins {
java
id("org.springframework.boot") version "3.4.3"
id("io.spring.dependency-management") version "1.1.7"
id("org.springframework.boot")
id("io.spring.dependency-management")
}

group = "fun.vs.fw"
version = "0.0.1-SNAPSHOT"

java {
toolchain {
languageVersion = JavaLanguageVersion.of(23)
languageVersion = JavaLanguageVersion.of(25)
}
}

repositories {
mavenCentral()
}

extra["springCloudVersion"] = "2024.0.0"

dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation("org.springframework.kafka:spring-kafka")
implementation("org.postgresql:postgresql:42.7.5")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.springframework.kafka:spring-kafka-test")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
testImplementation("org.testcontainers:kafka")
testImplementation("org.wiremock.integrations.testcontainers:wiremock-testcontainers-module:1.0-alpha-14")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.mockito:mockito-core:5.16.0")
implementation("org.springframework.kafka:spring-kafka:")
implementation("org.postgresql:postgresql:$postgresqlVersion")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.springframework.kafka:spring-kafka-test")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
testImplementation("org.testcontainers:kafka")
testImplementation("org.wiremock.integrations.testcontainers:wiremock-testcontainers-module:$wiremockTestcontainersVersion")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.mockito:mockito-core:$mockitoCoreVersion")
testImplementation("com.tngtech.archunit:archunit:$archunitVersion")
}

dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
mavenBom("org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion")
}
}

Expand Down
12 changes: 12 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
springBootVersion=3.5.6
springDependencyManagementVersion=1.1.7
springCloudVersion=2025.0.0
springKafkaVersion=3.2.2
postgresqlVersion=42.7.5
wiremockTestcontainersVersion=1.0-alpha-15
junitPlatformLauncherVersion=1.10.2
mockitoCoreVersion=5.20.0
archunitVersion=1.4.1
openFeignVersion=4.1.0
springBootTestcontainersVersion=3.2.2
testcontainersVersion=1.19.7
Binary file modified gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
12 changes: 4 additions & 8 deletions gradlew

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions gradlew.bat

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1 +1,16 @@
pluginManagement {
val springBootVersion: String by settings
val springDependencyManagementVersion: String by settings

repositories {
gradlePluginPortal()
mavenCentral()
}

plugins {
id("org.springframework.boot") version springBootVersion
id("io.spring.dependency-management") version springDependencyManagementVersion
}
}

rootProject.name = "fun-vs-fw"
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.kafka.annotation.EnableKafka;

@SpringBootApplication
@EnableFeignClients
@EnableKafka
public class TransactionCategorizationApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package fun.vs.fw.demo.config;


import fun.vs.fw.demo.domain.TransactionCategorizer;
import fun.vs.fw.demo.jpa.CategorizedTransactionRepositoryAdapter;
import fun.vs.fw.demo.jpa.TransactionalTransactionCategorizer;
import fun.vs.fw.demo.merchantdirectory.MerchantDirectoryAdapter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TransactionCategorizerConfiguration {

@Bean
public TransactionCategorizer transactionCategorizationService(
CategorizedTransactionRepositoryAdapter repositoryAdapter,
MerchantDirectoryAdapter merchantDirectoryAdapter) {
return new TransactionalTransactionCategorizer(
TransactionCategorizer.create(
repositoryAdapter,
repositoryAdapter,
merchantDirectoryAdapter
)
);
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package fun.vs.fw.demo.controller;


import fun.vs.fw.demo.repository.CategorizedTransaction;
import fun.vs.fw.demo.repository.CategorizedTransactionRepository;
import fun.vs.fw.demo.repository.CategoryBudget;
import fun.vs.fw.demo.domain.CategorizedTransaction.ExpenseCategory;
import fun.vs.fw.demo.domain.CategorizedTransactionPorts.FindBudgetsByCategory;
import fun.vs.fw.demo.domain.CategorizedTransactionPorts.FindByClientIdAndExpenseCategory;
import fun.vs.fw.demo.domain.CategorizedTransactionPorts.FindExpenseCategoriesByClient;
import fun.vs.fw.demo.domain.CategoryBudget;
import fun.vs.fw.demo.domain.Transaction.ClientId;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
Expand All @@ -12,27 +15,33 @@
import java.util.List;

@RestController
public class CategorizedTransactionController {
public class CategorizedTransactionController<R extends
FindByClientIdAndExpenseCategory &
FindBudgetsByCategory &
FindExpenseCategoriesByClient> {

private final CategorizedTransactionRepository repository;
private final R repository;

public CategorizedTransactionController(CategorizedTransactionRepository repository) {
public CategorizedTransactionController(R repository) {
this.repository = repository;
}

@GetMapping("/client/{clientId}/transactions")
public List<CategorizedTransaction> getTransactionsByClientAndCategory(@PathVariable String clientId,
@RequestParam String category) {
return repository.findByClientIdAndExpenseCategory(clientId, category);
public List<CategorizedTransactionResponse> getTransactionsByClientAndCategory(
@PathVariable String clientId,
@RequestParam String category) {
return repository.findBy(new ClientId(clientId), new ExpenseCategory(category))
.stream().map(CategorizedTransactionResponse::valueOf).toList();
}

@GetMapping("/client/{clientId}/categories-budget")
public List<CategoryBudget> getBudgetByCategory(@PathVariable String clientId) {
return repository.findBudgetsByCategory(clientId);
return repository.findBudgetsByCategory(new ClientId(clientId));
}

@GetMapping("/client/{clientId}/categories")
public List<String> getDistinctExpenseCategoriesByClientId(@PathVariable String clientId) {
return repository.findDistinctExpenseCategoriesByClientId(clientId);
return repository.findExpenseCategoriesBy(new ClientId(clientId))
.stream().map(ExpenseCategory::value).toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package fun.vs.fw.demo.controller;


import fun.vs.fw.demo.domain.CategorizedTransaction;

public record CategorizedTransactionResponse(
String transactionId,
String expenseCategory) {

public static CategorizedTransactionResponse valueOf(CategorizedTransaction categorizedTransaction) {
return new CategorizedTransactionResponse(
categorizedTransaction.transactionId().value(),
categorizedTransaction.expenseCategory().value()
);
}
}
48 changes: 48 additions & 0 deletions src/main/java/fun/vs/fw/demo/domain/CategorizedTransaction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package fun.vs.fw.demo.domain;


import fun.vs.fw.demo.domain.Transaction.AccountId;
import fun.vs.fw.demo.domain.Transaction.Amount;
import fun.vs.fw.demo.domain.Transaction.ClientId;
import fun.vs.fw.demo.domain.Transaction.TransactionId;

import java.util.Objects;

import static java.util.Objects.requireNonNull;

public record CategorizedTransaction(
CategorizedTransactionId id,
TransactionId transactionId,
ClientId clientId,
AccountId accountId,
Amount amount,
ExpenseCategory expenseCategory) {

public CategorizedTransaction {
requireNonNull(transactionId, "Transaction ID cannot be null");
requireNonNull(clientId, "Client ID cannot be null");
requireNonNull(accountId, "Account ID cannot be null");
requireNonNull(amount, "Transaction Amount cannot be null");
requireNonNull(expenseCategory, "Expense Category cannot be null");
}

public CategorizedTransaction withId(CategorizedTransactionId id) {
return new CategorizedTransaction(id, transactionId, clientId, accountId, amount, expenseCategory);
}

public record CategorizedTransactionId(Long value) {
public CategorizedTransactionId {
Objects.requireNonNull(value, "Transaction ID cannot be null");
}
}

public record ExpenseCategory(String value) {
public ExpenseCategory {
requireNonNull(value, "Expense category cannot be null");
if (value.isBlank()) {
throw new IllegalArgumentException("Expense category cannot be null or blank");
}
}
}
}

Loading