DEV Community

Dominik Kovács
Dominik Kovács

Posted on Originally published at solodev.sk on

Keeping dev-only beans out of the jar

The setup code nobody wants in production

Some infrastructure has to be created before the application can use it, and who creates it depends
on where the application runs. DynamoDB is a clear case. In production the tables are provisioned by
Terraform or CDK before anything deploys. Locally and in tests, nothing has provisioned them, so the
application has to do it itself or immediately fail on a missing table.

There is nothing built in to do it. Relational databases get schema.sql and Flyway, but the AWS
SDK has no equivalent, so the table creation ends up as an ApplicationRunner with a profile
limiting where it runs.

CatalogConfiguration.java

@Bean
@Profile("dev | test")
ApplicationRunner createProductTable(DynamoDbEnhancedClient enhancedClient) {
    return args -> enhancedClient.table("product", TableSchema.fromBean(Product.class))
            .createTable();
}
Enter fullscreen mode Exit fullscreen mode

TableSchema.fromBean reads the key and attributes off the annotated Product class, so the table
is created from the same mapping the application already uses to read and write it.

This works, and it ships. The runner is in the jar and createTable is in the jar, so the guarantee
is only as good as the active profiles staying right on every environment forever. A shared base
configuration that carries dev, a copied Helm chart, a machine where someone exported
SPRING_PROFILES_ACTIVE and forgot, and the code is live against a real table. Nothing warns you,
because as far as Spring is concerned the bean did what it was told.

Where the code lives, not what the profile says

src/test/java compiles to target/test-classes, and the Spring Boot plugin does not package that
directory. Move the table creation there and it is not in the artifact at all.

Building the same project with that bean in src/test/java gives a jar containing only the
application code.

BOOT-INF/classes/com/example/catalog/CatalogApplication.class
BOOT-INF/classes/com/example/catalog/Product.class
BOOT-INF/classes/com/example/catalog/ProductController.class
Enter fullscreen mode Exit fullscreen mode

No table creation anywhere in it. Test-scoped dependencies are absent from the runtime classpath
too, so a fake implementation that needs a container library does not drag it into production
either.

That is a different kind of guarantee from the profile version. A profile is evaluated at runtime,
which means the code is present and something decided not to call it. A classpath is decided when
the artifact is built. Production cannot create the table because the bytecode is not there, and
no configuration mistake can put it back.

The shape that falls out of this puts everything the application ships on one side and everything
that only runs on a developer machine on the other.

src/main/java/com/example/catalog/
    CatalogApplication.java
    Product.java
    ProductController.java
src/main/resources/
    application.yaml
src/test/java/com/example/catalog/
    TestCatalogApplication.java
    LocalDynamoDbConfiguration.java
    ProductRepositoryTest.java
src/test/resources/
    application.yaml
    application-dev.yaml
Enter fullscreen mode Exit fullscreen mode

1. TestCatalogApplication is the local entry point, and the only main method that knows
about any of this.

2. LocalDynamoDbConfiguration holds the container and the table setup.

3. ProductRepositoryTest is an ordinary test that imports that same configuration.

4. This application.yaml needs care. Both files answer to classpath:/application.yaml and
test classes come first, so it does not merge with the shipped file, it hides it. Anything the
application needs in every environment has to be repeated here or moved into a profile file
alongside it.

5. application-dev.yaml carries the ports and URLs that only make sense on a laptop.

Nothing under src/test is in the jar, which means the division is not a naming convention that has
to be policed. The build enforces it.

Starting the application from your tests

Moving that bean raises the obvious problem. spring-boot:run uses the main classpath, so the bean
you just moved is invisible when you start the application locally, which is exactly when you wanted
it.

Boot has a second goal for this. spring-boot:test-run starts the application with test classes and
test dependencies included, and it prefers a main class found in the test classes directory. So the
launcher lives in src/test/java next to everything else that only dev needs.

TestCatalogApplication.java

public class TestCatalogApplication {

    public static void main(String[] args) {
        SpringApplication.from(CatalogApplication::main)
                .with(LocalDynamoDbConfiguration.class)
                .run(args);
    }
}
Enter fullscreen mode Exit fullscreen mode

SpringApplication.from runs the real application and adds beans to it. The production entry point
is untouched, and there is no second @SpringBootApplication to keep in sync.

What it adds is the container, and the table creation that depends on it.

LocalDynamoDbConfiguration.java

@TestConfiguration(proxyBeanMethods = false)
class LocalDynamoDbConfiguration {

    @Bean
    @ServiceConnection
    LocalStackContainer localStack() {
        return new LocalStackContainer(DockerImageName.parse("localstack/localstack:4"));
    }

    @Bean
    ApplicationRunner createProductTable(DynamoDbEnhancedClient enhancedClient) {
        return args -> enhancedClient.table("product", TableSchema.fromBean(Product.class))
                .createTable();
    }
}
Enter fullscreen mode Exit fullscreen mode

1. @TestConfiguration applies only where something imports it, which is the launcher above.
Nothing here is picked up by starting the application normally.

2. @ServiceConnection is what removes the configuration. Spring Cloud AWS contributes a
factory for LocalStack, so the endpoint and credentials of the started container become the AWS
client configuration with nothing written in application.yaml.

Importing by name is also what lets a test reuse the whole setup. An integration test that wants the
same container and the same table asks for it.

ProductRepositoryTest.java

@SpringBootTest
@Import(LocalDynamoDbConfiguration.class)
class ProductRepositoryTest {

}
Enter fullscreen mode Exit fullscreen mode

The local run and the tests that opt in now share one definition of what the environment looks like,
and tests that do not import it are unaffected. That reuse is the practical argument for putting the
configuration in src/test/java rather than somewhere only the application can reach.

Now one command boots the whole thing.

mvn spring-boot:test-run -Dspring-boot.run.profiles=dev
Enter fullscreen mode Exit fullscreen mode

LocalStack starts, @ServiceConnection points the DynamoDB client at it, the runner creates the
table, and the application comes up talking to a real DynamoDB implementation. Gradle has the same
thing as bootTestRun.

spring-boot:test-run activates no profile on its own, so dev has to be named. On the command
line it applies to local runs only, and the test suite keeps whatever profiles its tests ask for.

The dev profile file belongs there too

Beans are not the only thing that leaks. Think about what a dev profile file actually holds. A port
chosen so two services can run side by side, third-party URLs pointing at a sandbox or a stub on
localhost, a log level nobody wants in production, credentials that are fake precisely because the
services they unlock are fake. None of it is meaningful outside the machine it was written for, and
src/main/resources/application-dev.yaml ships every line of it.

src/test/resources is left out of the jar exactly like src/test/java, so that is where the file
belongs.

src/test/resources/application-dev.yaml

server:
  port: 9090
spring:
  http:
    serviceclient:
      pricing:
        base-url: http://localhost:9091
logging:
  level:
    com.example.catalog: DEBUG
Enter fullscreen mode Exit fullscreen mode

Outbound HTTP is the part worth dwelling on, because tests and local development want different
things from the same client. A test stubs the response, since it is asserting on the caller rather
than the callee. Running locally you usually want the request to actually go somewhere, either a
service started on another port or a stub server standing in for it.

The key under serviceclient is the group name the client was declared with.

@ImportHttpServices(group = "pricing", types = PricingClient.class)
Enter fullscreen mode Exit fullscreen mode

Starting with dev active reads the file, so the client issues real requests to port 9091. The test
suite does not activate it, so the same client stays stubbed there. Two behaviours from one
declaration, and none of those local values are in the artifact.

Production configuration stays where it always was, in src/main/resources/application.yaml,
pointing at the real service. There is no dev block sitting next to it for somebody to activate by
accident.

What profiles are still good for

None of this makes profiles the wrong tool. They are the wrong tool for one specific job, which
is keeping code out of production, because they answer at runtime a question the build already
knows the answer to. Choosing between local and test behaviour, as dev does above, is exactly
what they are for, and it works the same inside src/test.

The distinction worth keeping is between choosing behaviour and excluding code. Profiles choose.
Only the classpath excludes.

When this is not enough

A deployed environment runs the real jar. Staging, a shared dev cluster, a preview deployment, all
of them get the artifact with target/test-classes left out, so a bean in src/test/java cannot
help them. If a deployed environment genuinely needs to create its own tables, this technique does
not reach it, and the honest options are a separate module that only that environment depends on,
or a profile with the leak risk accepted and guarded.

The technique fits the case where the code is for the machine doing the building. Local runs and the
test suite both use the test classpath, which is why one bean can serve both, and why production
never has to be trusted to skip it.

Top comments (0)