Bold vision of development of backend services with Spring Boot

by Marek Hudyma

Purpose of vision:

  • start discussion
  • inspire
  • make agreements inside team across some topics

Technical assumptions:

  • used Java 14 with flag --enable-preview
  • used Spring Boot 2.3.2.RELEASE

Main architectural assumptions:

  • Do not control flow by exceptions
  • Application uses CQRS
  • Follow Domain-driven design principles
    (not covered)

Do not control flow by exceptions

"An exception is abnormal condition that arises in a code sequence at run time. In other words, an exception is a run-time error."
"Java’s exception-handling statements should not be considered a general mechanism for nonlocal branching. If you do so, it will only confuse your code and make it hard to maintain."
Java the Complete Reference, Tenth edition

The principle of least astonishment (POLA)

The principle of least astonishment (POLA), also called the principle of least surprise.
The principle means that a component of a system should behave in a way that most users will expect it to behave; the behavior should not astonish or surprise users.

Arguments to not throw business exceptions

  • Breaking Principle of least astonishment
  • Exceptions can be used as sophisticated GOTO statements
  • Exceptions pollutes you application logs
  • Usage of exceptions make your code more difficult to read, understand and maintain
  • Exceptions degrade speed of application

Example how to return errors

		
    public Result<Error, Account> execute(Account account) {
        return accountRepository.findById(account.getId())
                .map(a -> Result.fail(a, Error.ACCOUNT_ALREADY_EXIST))
                .orElseGet(() -> Result.ok(accountRepository.save(account));
    }

    public enum Error {
        ACCOUNT_ALREADY_EXIST
    }
    					

CQRS

"Command–query separation (CQS) is a principle of imperative computer programming [...] It states that every method should either be a command that performs an action, or a query that returns data to the caller, but not both. In other words, Asking a question should not change the answer."
"Command query responsibility segregation (CQRS) applies the CQS principle by using separate Query and Command objects to retrieve and modify data, respectively."

Command

public interface Command<INPUT, RESULT> {

    RESULT execute(INPUT input);

}						
						
	
@Component
@RequiredArgsConstructor
public class CreateAccountCommand implements Command> {

    private final AccountRepository accountRepository;

    public Result<Error, Account> execute(Account account) {
        return accountRepository.findById(account.getId())
                .map(a -> Result.fail(a, Error.ACCOUNT_ALREADY_EXIST))
                .orElseGet(() -> save(account));
    }

    private Result<Error, Account> save(Account account) {
        try {
            return Result.ok(accountRepository.save(account));
        } catch (DataIntegrityViolationException e) {
            return Result.fail(account, Error.ACCOUNT_ALREADY_EXIST);
        }
    }

    public enum Error {
        ACCOUNT_ALREADY_EXIST
    }

}
						

Query

public interface Query<INPUT, RESULT> {

    RESULT execute(INPUT input);

}						
						
	

@Component
@RequiredArgsConstructor
class GetAccountQuery implements Query<AccountId, Result<Error, Account>> {

    private final AccountRepository accountRepository;

    @Transactional(readOnly = true)
    public Result<Error, Account> execute(AccountId accountId) {
        return accountRepository.findById(accountId)
                .map(Result::<Error, Account>ok)
                .orElse(Result.fail(Error.ACCOUNT_NOT_FOUND));
    }

    public enum Error {
        ACCOUNT_NOT_FOUND
    }

}
						

Advantages of CQRS

  • Clear separation between read and write operations
  • All operations are separated (fit to Single responsibility principle)
  • May simplify understanding of domain
  • Easier to split work
  • Event Sourcing combines nicely with CQRS
  • Easy to find all places where you modify data

Follow Domain-driven design prinicples

  • Not covered here

DTO

  • POJO - Plain Old Java Object
  • Immutable
  • Don't reuse DTOs

DTO in Java 14

	
// TODO remove @JsonProperty when Jackson support records
public record CreateAccountDto(

        @NotNull
        @JsonProperty("id")
        UUID id,

        @NotEmpty
        @JsonProperty("name")
        String name,

        @NotNull
        @Min(0)
        @Max(100)
        @JsonProperty("scoring")
        Integer scoring
) {

}
						

DTO with Lombok

	
	@Builder
	@RequiredArgsConstructor
	@Data
	public class CreateAccountDto {
	
	    @NotNull
	    private final UUID id;
	
	    @NotBlank
	    @Max(50)
	    private final String name;
	
	    @Min(0)
	    @Max(100)
	    private final int scoring;
	
	}
						

Validation in HTTP layer

  • by annotations from package javax.validation.constraints.*.
  • by custom validators
  • validation on this level must not call service layer / storage.

Controllers

  • handle http call
  • make simple validation of `DTO` (e.g. using @Valid annotation)
  • convert DTO to the object accepted by Service layer
  • handle response from `Service Layer` and map it to `HTTP` response.

Converters

Use Spring org.springframework.core.convert.converter.Converter interface to create Converters. It is a functional interface that can be tested in a separation.
	
public class CreateAccountDtoToAccountConverter 
	implements Converter<CreateAccountDto, Account> {

    @Override
    public Account convert(CreateAccountDto source) {
        return Account.builder()
                .id(AccountId.from(source.id()))
                .name(source.name())
                .scoring(source.scoring())
                .build();
    }
}
						

Usage of Service layer.

  • Service should not use object from layer above, so DTOs should not be passed to services directly as DTOs
  • Pass argument to the method, e.g. service.call(argument1, argument2);
  • Convert DTO to Entity object
  • Service can accept interface, DTO can implement this interface, than it can be passed directly to service

Exceptions from service

  • HTTP codes should not be returned by exceptions
  • Predictable exceptions should be handled by RESPONSE
  • Exception handler can be added exceptionally, for the exception types that cannot be handled in a different way, e.g.: MethodArgumentTypeMismatchException, MethodArgumentNotValidException
  • Use a proper problem JSON for handling errors, by following Zalando standard

Domain layer

  • Use Commands to execute logic
  • Use Queries to retrieve data
  • Don't execute long tasks inside database transaction (e.g. HTTP call)
  • use @Transactional / @Transactional(readOnly = true)

Data access layer

  • Use as little business logic as possible
  • Test it with IntegrationTests

Model

  • use Entity / Aggregate / Value Object patters
  • Identified by id
  • always use createdAt, updatedAt properties
  • muttable
  • no setters
  • expose behavior
  • Consider introducing key types
  • @EmbeddedId
    @AttributeOverride(name = "value", 
    	column = @Column(name = "id"))
    private AccountId id;
    							

Unit Tests

  • Test logic
  • Test Controllers with @WebMvcTest (serialization, error codes, HTTP statuses)

IntegrationTests

  • Test integration with external components (Database, AWS, REST calls)
  • Uses current context - easy to debug
  • Test corner cases
  • Can use e.g. Repositories to prepare database state
  • Can use DTOs
  • Use awaitibility library for testing asynchronous code
  • await().atMost(10, SECONDS).until(() -> {
    	// condition
    });
            					

Functional Tests

  • Make blackbox tests
  • Don't prepare database state manually - use offical interfaces only
  • Cannot use production code like DTOs
  • Don't reuse JSONs - directory path the same like package

Test builders

@RequiredArgsConstructor
public class AccountTestBuilder extends Account.AccountBuilder {

    private final int seed;

    public Account.AccountBuilder withTestDefaults() {
        return Account.builder()
                .id(AccountId.from(seed))
                .name(format("name.%d", seed))
                .scoring(seed)
                .version(seed);
    }
}