When I ask AI to build a project, can it deliver simple architecture with senior-level security thinking, or does it just default to over-engineered tutorials?

Reading time:

36–54 minutes
I Tested If Codex CLI Writes Senior-Level Spring

Image generated with AI

Everyone is making videos about how AI makes them money. “I made 10k using AI” and “I build a SaaS in 20 minutes.”. I’m more interested in a different question: Does it make good code?

Because here’s what those videos don’t show you. The junior developer who vibe-coded an entire backend in a weekend and shipped it to production. The non-technical founder who built an API without knowing what an API actually does. The beautiful architecture diagram that collapses the moment you add a second user. The security hole that nobody asked about because nobody knew to ask.

AI tools have made it easier than ever to produce code that looks finished. Whether it’s actually correct, secure, and maintainable, is a different conversation. One that requires someone who’s seen what happens six months later, when the demo is in production and something breaks in a way the AI never anticipated.

I’m a senior developer. I’ve reviewed a lot of pull requests and I know what good code looks like, and I know the shortcuts that only show up under pressure.

So I ran the experiment with Codex CLI on a Spring Boot microservice backed by DynamoDB, and reviewed the output the same way I’d review a junior developer’s pull request. Architecture, security, test quality, edge cases. Everything. Can Codex CLI write senior-level code?

Fair warning: This is a long post. Grab a coffee.

Table Of Content

The Expected Architecture

Before running the prompt, I had a clear picture of what a correct implementation looks like. I wrote a blog post where I covered the architecture, how should one create a DynamoDB local integration with Spring Boot in detail. If you haven’t read it, start there. For those who have, here’s a quick reminder of what correct looks like:

It is a simple layered architecture with DynamoDBLocal. Controller handles HTTP. Service owns business logic. Repository talks to DynamoDB. Let’s look at the data structure:

User ID (PK)Product ID (SK)Unit Price
user-1product-110.50
user-2product-220.90
user-3product-371.49

The composite key is non-negotiable for this use case. A shopping cart is always scoped to a user, and each product is a distinct row, not an item in a list.

I ran these experiments using Codex CLI with a ChatGPT Plus subscription, GPT-5.5, and medium/high/extra high reasoning depending on the run.

How will I rate this

Pass — No significant issues, senior-level code.

Fail — Does not meet senior developer standard. It contains major bugs, security issues, or it is completely wrongly generated.

⚠️ Partial — It’s something between. It works, but a senior developer would most probably choose a different implementation. Contains readability issues, boilerplate, or design decisions that would cause problems at team or production level.

1. The loose prompt

The prompt was executed in terminal using Codex CLI with GPT-5.5 model and medium reasoning.

codex
In the current directory create a small Spring Boot Microservice, that manages shopping cart for an online store. Data will be stored in AWS DynamoDB. For local development environment use DynamoDBLocal. DynamoDB table must store the following information:
 
  * user id - this is the partition key of the table, must be string
  * product id - must be string
  * unit price - monetary value

Microservice have to expose the following REST endpoints:
 
  * reading the shopping cart content,
  * add product to shopping cart,
  * remove product from shopping cart,
  * remove the whole shopping cart.

Additional features:
 
  * format the source code using spring java formatter
  * ensure 85% code coverage in tests

Constraints:
 
  * prevent duplicate entries for product
  * unit price cannot be less than or equal to zero
  * don't use docker

A few things I left out on purpose:

  • No sort key. I specified userId as the partition key but said nothing about productId as a sort key. A correct DynamoDB implementation needs a composite key here, I wanted to see if Codex CLI would figure that out.
  • No versions. I wanted to see if Codex CLI defaults to stable releases.
  • No security. I want to know how will Codex CLI handle it.
  • No build tool. I didn’t specify Maven or Gradle, nor their versions. This is a genuine preference split in the Java ecosystem. Both are valid, but your team probably has a standard. I wanted to see what Codex CLI defaults to when left to choose.

These aren’t trick questions. They’re the decisions a senior developer makes automatically. The prompt reflects what a real user story looks like. Not a technical specification.

Here is what Codex CLI produced

Codex CLI produced 23 files, in 8 minutes and 37 seconds. In its reasoning it specifically followed the prompt and stuck to features and constraints. The line coverage was 97.41%. You can check out the code on GitHub. The project compiles, tests are passing — and it is different from what I expected. Codex CLI didn’t build what I would have built. It built something simpler.

Let’s start with the foundation, the tech stack.

Tech StackCodex CLI versionLatest LTS
Java version2125
spring-boot-starter-parent3.5.04.1.0
software.amazon.awssdk.bom2.31.542.46.10

4 dependencies:

  • spring-boot-starter-validation
  • spring-boot-starter-web
  • spring-boot-starter-test
  • software.amazon.awssdk.dynamodb

At the time of writing this article the current latest version of Spring Boot is 4.1.0. Spring Boot 3.5.0 will reach end of OSS support at the end of June 2026. Java 21 is still a valid LTS choice, but the current LTS is Java 25, released September 2025.

Architecture Review

First I reviewed how the system is structured before a single line of business logic is read. Here is what I found.

Project structure ⚠️

Codex CLI went with a mixture of layered and hexagonal architecture. Configuration is correctly separated from business logic. It defined the ShoppingCartRepository in domain package as an interface, which serves as an outbound port to the outer layer, the infrastructure. DynamoDbShoppingCartRepository in infrastructure package serves as an adapter, which correctly implements this interfaces and communicates to the database. However the rest of the project doesn’t follow this separation. For a simple project like this a senior developer would go with layered architecture, because we have only 1 entry point, that’s the controller, and 1 persistence layer.

Project structure
 src/main
  ├── java/com/example/shoppingcart
  │   ├── ShoppingCartApplication.java
  │   ├── api
  │   │   ├── AddProductRequest.java
  │   │   ├── ApiExceptionHandler.java
  │   │   ├── ShoppingCartController.java
  │   │   └── ShoppingCartResponse.java
  │   ├── config
  │   │   ├── DynamoDbConfiguration.java
  │   │   └── DynamoDbProperties.java
  │   ├── domain
  │   │   ├── CartItem.java
  │   │   ├── DuplicateProductException.java
  │   │   ├── ShoppingCartRepository.java
  │   │   └── ShoppingCartService.java
  │   └── infrastructure
  │       └── DynamoDbShoppingCartRepository.java
  └── resources
      └── application.yml

Data Model ✅

Codex CLI created composite key without being told. It understood DynamoDB modeling well enough to make the right call.

Code Coverage ✅

The generated source code surpassed my requirement and produced 98% code coverage.

Profiles for different execution ⚠️

No profiles used in the project. From a senior developer I would expect to create a application-{profile}.yml for local development and a standalone test application.yml. Current implementation use environment variables and if forgotten production silently uses local defaults. That’s not a tradeoff a senior developer would accept without documenting it explicitly.

application.yml
shopping-cart:
  dynamodb:
    endpoint: ${DYNAMODB_ENDPOINT:http://localhost:8000}
    region: ${AWS_REGION:eu-central-1}
    access-key: ${AWS_ACCESS_KEY_ID:local}
    secret-key: ${AWS_SECRET_ACCESS_KEY:local}
    table-name: ${SHOPPING_CART_TABLE:shopping-cart}
    create-table: ${CREATE_DYNAMODB_TABLE:true}

DynamoDB Local ⚠️

Codex CLI followed my requirement to not use docker for running the database. It created a script that would download a compressed DynamoDBLocal file, extract it to a directory and run it. The process is documented in the readme, and is required to run before the application starts. The bash script is very solid, however, even without Docker, a senior developer would expect a solution that starts automatically as part of the test or application context and not a manually executed script.

run-dynamodb-local.sh
#!/usr/bin/env bash

set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DYNAMODB_DIR="${ROOT_DIR}/dynamodb-local"
ARCHIVE="${DYNAMODB_DIR}/dynamodb_local_latest.tar.gz"

mkdir -p "${DYNAMODB_DIR}"

if [[ ! -f "${DYNAMODB_DIR}/DynamoDBLocal.jar" ]]; then
	curl --fail --location \
		--output "${ARCHIVE}" \
		https://d1ni2b6xgvw0s0.cloudfront.net/dynamodb_local_latest.tar.gz
	tar -xzf "${ARCHIVE}" -C "${DYNAMODB_DIR}"
fi

exec java \
	-Djava.library.path="${DYNAMODB_DIR}/DynamoDBLocal_lib" \
	-jar "${DYNAMODB_DIR}/DynamoDBLocal.jar" \
	-sharedDb \
	-dbPath "${DYNAMODB_DIR}"

Security ❌

Controller is designed to request shopping cart for any user by passing their userId. It is a data privacy violation and a fundamental gap a senior developer would definitely catch. This one is not a loose prompt problem, but basic API security concern that a senior developer thinks about automatically. I would expect Codex CLI to point it out and ask about next steps. This is where I don’t approve.

ShoppingCartController.java
@GetMapping("/{userId}")
public ShoppingCartResponse getCart(@PathVariable @NotBlank String userId) {
		return new ShoppingCartResponse(userId, service.getCart(userId));
}

Code Review

Let’s see if the generated code is a senior developer level quality.

Repository ⚠️

Low-level DynamoDbClient was used to implement the repository logic. It is more transparent on what is happening, but you have to map Java objects to DynamoDB items. Higher-level DynamoDbEnhancedClient offers a more JPA/Hibernate’s style of mapping classes to tables. See an example in this post. AWS doesn’t prefer nor suggest any of them, the right choice depends on your team’s familiarity with the SDK.

The bigger problem I see is that the schema generation happens in the @PostConstruct method of the repository. This is not where it belongs. In cloud environment the database is already provisioned, either manually or via terraform. I would be strongly against coupling table creation to the repository. For a local environment, I would expect this to be in a separate layer, for example a configuration annotated with a @Profile("local"). One more detail: With DynamoDBLocal there is no need to define billing.

DynamoDbShoppingCartRepository.java
@PostConstruct
void createTable() {
		if (!properties.createTable()) {
			return;
		}
		try {
			dynamoDb.createTable(CreateTableRequest.builder()
				.tableName(properties.tableName())
				.billingMode(BillingMode.PAY_PER_REQUEST)
				.attributeDefinitions(
						AttributeDefinition.builder()
							.attributeName(USER_ID)
							.attributeType(ScalarAttributeType.S)
							.build(),
						AttributeDefinition.builder()
							.attributeName(PRODUCT_ID)
							.attributeType(ScalarAttributeType.S)
							.build())
				.keySchema(KeySchemaElement.builder().attributeName(USER_ID).keyType(KeyType.HASH).build(),
						KeySchemaElement.builder().attributeName(PRODUCT_ID).keyType(KeyType.RANGE).build())
				.build());
		}
		catch (ResourceInUseException ignored) {
		  // The table already exists.
		}
}

For removing all items from shopping cart batchWriteItem is an overkill. It can return unprocessed items, and you should retry the batch operation on those items. Remove cart operation ignores this. The batch size is correct, but never inspected for unprocessed items.

DynamoDbShoppingCartRepository.java
@Override
public void removeCart(String userId) {
		List<WriteRequest> deletes = new ArrayList<>();
		for (CartItem item : findByUserId(userId)) {
        deletes.add(WriteRequest.builder()
           .deleteRequest(DeleteRequest.builder().key(key(userId, item.productId())).build())
			 	   .build());
		}
		for (int start = 0; start < deletes.size(); start += 25) {
        int end = Math.min(start + 25, deletes.size());
        dynamoDb.batchWriteItem(BatchWriteItemRequest.builder()
				   .requestItems(Map.of(properties.tableName(), deletes.subList(start, end)))
				   .build());
		}
	}

The implementation in the repository class is verbose and requires manual attribute mapping for every field. If we added more attributes to CartItem, this method would become more difficult to read and a harder to maintain. Also the query method doesn’t handle paged results.

DynamoDbShoppingCartRepository.java
@Override
public List<CartItem> findByUserId(String userId) {
		var response = dynamoDb.query(QueryRequest.builder()
			.tableName(properties.tableName())
			.keyConditionExpression("#userId = :userId")
			.expressionAttributeNames(Map.of("#userId", USER_ID))
			.expressionAttributeValues(Map.of(":userId", stringValue(userId)))
			.build());
		return response.items()
			.stream()
			.map(item -> new CartItem(item.get(PRODUCT_ID).s(), new java.math.BigDecimal(item.get(UNIT_PRICE).n())))
			.toList();
}

Domain Object ⚠️

The CartItem was designed as a Java record with 2 attributes, productId and unitPrice. The record itself is not the problem. What bothers me is that this is the only domain object, so the cart boundary is not visible in the domain model. A value object carries data. An entity has a unique identity within the system. Looking at the CartItem gives very little information about the table attributes. What is the partition value? What is the sort value? How many cart items can a user have? Which other relations can product and price be part of? What are the names of the table attributes?

CartItem.java
public record CartItem(String productId, BigDecimal unitPrice) {
}

To answer these questions you have to dig into the repository implementation. Looking at the record alone, you cannot tell if productId references an external product, or if unitPrice is the price captured at the time of adding to the cart.

Prevent Duplicates ✅

Codex CLI correctly handles the conditional expression and prevents adding duplicates. When we try to insert duplicate product, the method throws ConditionalCheckFailedException, which translates to 409 response code.

Controller ⚠️

The REST design is clean. All four endpoints use the correct HTTP methods and response codes, and input validation is properly applied with @NotBlank, @DecimalMin and the request record.

The problem is structural and runs through every endpoint, as userId is accepted as a path variable. Any caller can read, modify, or clear any user’s cart simply by changing the path. There is no authentication, no ownership check, nothing tying the request to the user making it. This is the same privacy violation I flagged under Security, but it is worth restating here, because it is not a single weak endpoint. It is the foundation the entire controller is built on. A senior developer would never ship a controller where the user identity is whatever the caller types into the URL.

ShoppingCartController.java
@GetMapping("/{userId}")
public ShoppingCartResponse getCart(@PathVariable @NotBlank String userId) {
		return new ShoppingCartResponse(userId, service.getCart(userId));
}
	
@PostMapping("/{userId}/products")
@ResponseStatus(HttpStatus.CREATED)
public CartItem addProduct(@PathVariable @NotBlank String userId, @Valid @RequestBody AddProductRequest request) {
		return service.addProduct(userId, request.productId(), request.unitPrice());
}

@DeleteMapping("/{userId}/products/{productId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void removeProduct(@PathVariable @NotBlank String userId, @PathVariable @NotBlank String productId) {
		service.removeProduct(userId, productId);
}

@DeleteMapping("/{userId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void removeCart(@PathVariable @NotBlank String userId) {
		service.removeCart(userId);
}

Adding new product correctly implements validation for product price with @DecimalMin.

AddProductRequest.java
public record AddProductRequest(@NotBlank String productId,
		@NotNull @DecimalMin(value = "0.0", inclusive = false) BigDecimal unitPrice) {
}

The reason this section is not Failed, is that the endpoints could map to a “current user” instead of an userId in the path, for example deriving identity from the security context, and the ownership check would then live in the service layer, failing cleanly when no user is authenticated.

Tests ⚠️

Code coverage reached 97.41%, exceeding the specified 85% target. Coverage measures lines, but not behavior. All tests follow a unit test style. Each layer mocks the one below it, and no integration test verifies actual DynamoDB behavior. High coverage without integration tests gives a false sense of confidence.

ShoppingCartServiceTests.java
private final ShoppingCartRepository repository = mock(ShoppingCartRepository.class);

private final ShoppingCartService service = new ShoppingCartService(this.repository);

@Test
void delegatesCartOperationsToRepository() {
		CartItem item = new CartItem("product-1", new BigDecimal("12.50"));
		when(this.repository.findByUserId("user-1")).thenReturn(List.of(item));

		assertThat(this.service.getCart("user-1")).containsExactly(item);
		assertThat(this.service.addProduct("user-1", "product-1", new BigDecimal("12.50"))).isEqualTo(item);
		this.service.removeProduct("user-1", "product-1");
		this.service.removeCart("user-1");

		verify(this.repository).add("user-1", item);
		verify(this.repository).removeProduct("user-1", "product-1");
		verify(this.repository).removeCart("user-1");
}
DynamoDbShoppingCartRepositoryTests.java
private final DynamoDbClient dynamoDb = mock(DynamoDbClient.class);

private final DynamoDbProperties properties = properties(true);

private final DynamoDbShoppingCartRepository repository = new DynamoDbShoppingCartRepository(this.dynamoDb,
			this.properties);
			
@Test
void findsCartItems() {
		when(this.dynamoDb.query(any(QueryRequest.class)))
			.thenReturn(QueryResponse.builder().items(item("user-1", "product-1", "12.50")).build());

		assertThat(this.repository.findByUserId("user-1"))
			.containsExactly(new CartItem("product-1", new BigDecimal("12.50")));
}

A senior developer ensures an expected behavior is tested properly, not just covered.

First prompt conclusion

Summary
3
⚠️7
1

From such a capable model I would expect a better result even for such a loose prompt with no technical specifications. The biggest flaw is clearly security. The manual DynamoDB setup affects the ability to introduce automated tests, since it requires someone to start the database first. My biggest surprise though, was a contradiction in its own design reasoning. Codex CLI chose the low-level AWS SDK client specifically to avoid abstraction and keep the persistence layer transparent. Then, in the same codebase, it introduced abstraction by defining the repository as an interface acting as a port. It rejects abstraction in one decision and add it in the next.

2. Skill And High Reasoning

Before I reviewed the code from first prompt, my plan was to guide Codex CLI to senior developer output. After the review, I decided to do something else. This is a simple microservice. A model like GPT-5.5 on high reasoning should not need much guidance to get it right. So instead of patching the original project, I started from scratch with the same prompt, higher reasoning, and lightweight guidance. Just enough to set expectations, not enough to do the thinking for it. For this part I’m using GPT-5.5 with high reasoning, and a skill.

~/.codex/skills/senior-spring-developer/SKILL.md
---
name: senior-spring-developer
description: Use when creating or extending a Spring Boot microservice.
---

## Versions
- Prefer an installed LTS Java version.
- Use the latest stable Spring Boot version compatible with the selected Java version.
- Use the latest AWS SDK v2.
- Explain any decision not to use the latest stable version.

## Code Style
- Prefer Spring Boot and Java capabilities over custom infrastructure
- Do not introduce abstractions for possible future implementations

## Security
- Never expose sensitive data in logs or API responses
- Verify the default profile doesn't use default values for sensitive data
Prompt
In the current directory create a small Spring Boot Microservice using $senior-spring-developer, that manages
shopping cart for an online store. Data will be stored in AWS DynamoDB. For local development environment use
DynamoDBLocal. DynamoDB table must store the following information:
 
    * user id - this is the partition key of the table, must be string
    * product id - must be string
    * unit price - monetary value
 
Microservice have to expose the following REST endpoints:
 
    * reading the shopping cart content,
    * add product to shopping cart,
    * remove product from shopping cart,
    * remove the whole shopping cart.
 
Additional features:
 
    * format the source code using spring java formatter
    * ensure 85% code coverage in tests
 
Constraints:
 
    * prevent duplicate entries for product
    * unit price cannot be less than or equal to zero
    * don't use docker

First thing I noticed is that Codex CLI picked up a neighboring project that uses script to download and run DynamoDB Local. To eliminate this “inspiration” I moved to the /tmp directory and executed the prompt there. That’s a useful lesson, Codex CLI can read context from the parent git project, even code you did not point it to.

Prompt Result

Execution took 10 minutes and 44 seconds, lines covered 97.54%, and 30 files produced. You can check out the code on GitHub. As I was scrolling through the reasoning, I noticed this line:

I find it interesting to consider my previous solutions, especially when it comes to learning new patterns. I need to check the latest version of Spring Boot and the AWS SDK changelog.

My SKILL told it to use the latest stable versions, and here it was, deciding to verify them against the previous solutions. I cannot prove the skill caused this reasoning, though. It is the first concrete sign that the skill is shaping how Codex CLI approaches the task.

Tech StackCodex CLI version
Java version25
spring-boot-starter-parent4.1.0
software.amazon.awssdk.bom2.46.10

Same 4 dependencies:

  • spring-boot-starter-validation
  • spring-boot-starter-web
  • spring-boot-starter-test
  • software.amazon.awssdk.dynamodb

Architecture Review

Versions are good this time, let’s check what is the result with skill and higher reasoning.

Project structure ⚠️

Layers are at first sight nicely separated and grouped together based on the responsibilities. Uses the typical Spring like package by layer structure. On closer look, though, the placement is not quite right. The DuplicateProductException lives in the service, but it is thrown in the repository as you will see later. The repository package carries an interface with a single implementation.

Project Structure
src/main/
  ├── java/
  │   └── com/
  │       └── example/
  │           └── cart/
  │               ├── ShoppingCartApplication.java
  │               ├── api/
  │               │   ├── AddCartItemRequest.java
  │               │   ├── ApiExceptionHandler.java
  │               │   └── ShoppingCartController.java
  │               ├── config/
  │               │   ├── DynamoDbConfiguration.java
  │               │   ├── DynamoDbProperties.java
  │               │   └── DynamoDbTableInitializer.java
  │               ├── domain/
  │               │   ├── Cart.java
  │               │   └── CartItem.java
  │               ├── repository/
  │               │   ├── CartRepository.java
  │               │   └── DynamoDbCartRepository.java
  │               └── service/
  │                   ├── DuplicateProductException.java
  │                   └── ShoppingCartService.java
  └── resources/
      ├── application-local.yml
      └── application.yml

Data Model ✅

Composite key was again used without being told.

Code Coverage ✅

Lines covered exceeded the prompt requirement.

Profiles for different execution ✅

Separate profile for local development. Local profile extends the default one with an endpoint, that has to be specified for the local DynamoDB runtime.

application.yml
app:
  dynamodb:
    table-name: shopping-carts
    region: ${AWS_REGION:us-east-1}
    create-table: false
application-local.yml
app:
  dynamodb:
    endpoint: http://localhost:8000
    create-table: true

DynamoDB Local ⚠️

Same approach as the first run. Codex CLI again generated a shell script that downloads, extracts, and starts DynamoDB Local.

Security ❌

Controller is designed the same way as previously. It exposes the shopping cart for any user by passing their userId.

Code Review

Repository ⚠️

Codex CLI again used the low-level SDK to implement the repository logic. Table creation was moved from the repository layer into configuration, which is very nice to see as that was one of my criticisms from the first run. The same interface abstraction was introduced to separate the service from database operations. I consider the interface in this case a bigger issue. With a single implementation of the repository this abstraction seems to me like preparation for future implementation, that doesn’t exist. It directly contradicts an instruction in my own skill: do not introduce abstractions for possible future implementations. Codex CLI added one anyway.

Duplicate handling is correct: it catches the ConditionalCheckFailedException thrown by the SDK and translates it into a DuplicateProductException. The placement is not correct. The exception is placed in the service package and it is thrown from repository, which breaks the boundary between the domain and service layers.

DynamoDbCartRepository.java
import com.example.cart.service.DuplicateProductException;

@Override
public void add(String userId, CartItem item) {
		try {
			client.putItem(PutItemRequest.builder()
				.tableName(tableName)
				.item(item(userId, item))
				.conditionExpression("attribute_not_exists(#productId)")
				.expressionAttributeNames(Map.of("#productId", PRODUCT_ID))
				.build());
		}
		catch (ConditionalCheckFailedException ex) {
			throw new DuplicateProductException(userId, item.productId(), ex);
		}
}

Previously batchWriteItem was used to remove the shopping cart. This time Codex CLI decided to remove each item one by one, and also provided the reasoning chain:

Reviewing batch write issues

When using DynamoDB’s batch write, it’s possible to end up with unprocessed items, which means implementing a retry mechanism is important. But is this too complex? Perhaps a method like writeBatch could continuously retry until there are no unprocessed items left, possibly limiting to a maximum of 5 attempts with exponential backoff? Alternatively, using individual deletes might be better, especially since the number of items in the cart can vary. I could implement a deleteBatch method to manage this while considering possible throttling issues.

DynamoDbCartRepository.java
@Override
public void remove(String userId, String productId) {
		client.deleteItem(deleteRequest(userId, productId));
}

@Override
public void removeAll(String userId) {
		findByUserId(userId).forEach(item -> client.deleteItem(deleteRequest(userId, item.productId())));
}

In this example I would have made the same decision, one-by-one delete is simpler than handling unprocessed items. One thing I would change is making sure findByUserId reads with strongly consistent read.

Domain Object ⚠️

The domain model reuses the CartItem record and adds a new Cart record to represent the user as the owner of the cart items. The Cart is in the domain package, however, on closer inspection, it is used only as a response wrapper. It describes the ownership relation between a user and their cart items more clearly than before, but it is never used in business logic, only returned out of it. Cart is basically just a DTO.

Cart.java
public record Cart(String userId, List<CartItem> items) {

	public Cart {
		items = List.copyOf(items);
	}

}

Prevent Duplicates ✅

Duplicates are handled the same exact way as previously.

Controller ⚠️

The controller was not changed at all. REST design and validation are solid, but all endpoints allow modifying the shopping cart simply by passing userId.

Tests ⚠️

Tests are slightly changed but not improved. Test classes now include @BeforeEach annotation that resets underlying mocked classes.

Java
@BeforeEach
void setUp() {
		repository = new DynamoDbCartRepository(client, 
		       new DynamoDbProperties("shopping-carts", Region.US_EAST_1, null, false));
}

Test package doesn’t contain any application.yml, therefore the DynamoDB properties configuration can not be tested with custom values, since they are inherited from source package.

Controller tests cover only the happy path.

Java
@Test
void delegatesAllEndpoints() {
		Cart cart = new Cart("user-1", List.of());
		when(service.getCart("user-1")).thenReturn(cart);

		assertThat(controller.getCart("user-1")).isSameAs(cart);

		AddCartItemRequest request = new AddCartItemRequest("product-1", new BigDecimal("12.50"));
		controller.addProduct("user-1", request);
		controller.removeProduct("user-1", "product-1");
		controller.removeCart("user-1");

		verify(service).addProduct("user-1", new CartItem("product-1", new BigDecimal("12.50")));
		verify(service).removeProduct("user-1", "product-1");
		verify(service).removeCart("user-1");
}

Exceptions are handled in a new test class, which evaluates the ProblemDetail.

Java
@Test
void mapsDuplicateProduct() {
		DuplicateProductException exception = new DuplicateProductException("user-1", "product-1",
				new RuntimeException());

		var problem = handler.duplicateProduct(exception);

		assertThat(problem.getStatus()).isEqualTo(HttpStatus.CONFLICT.value());
		assertThat(problem.getTitle()).isEqualTo("Duplicate product");
		assertThat(problem.getProperties()).containsKey("timestamp").doesNotContainKey("errors");
}

Missing integration tests still provide false confidence about the functionality.

Second prompt conclusion

Summary
4
⚠️7
1

In the reasoning and some of the result we can see how skill influenced the behavior, but not on every topics. Versions, Spring and Java capabilities were followed. It completely ignored the security, or maybe it just doesn’t consider a shopping cart a security-sensitive topic. One or the other, a senior developer would address this concern.

My SKILL.md defined a rule to not introduce abstractions for possible future implementations and Codex CLI introduced a single-implementation interface anyway. So either a skill is not a guarantee, or my rule was not specific enough to override the model’s default toward that pattern.

3. Navigated Skill

The skill for this run did not come from a style guide. Every principle in it is a direct response to something the first two runs got wrong. I took the gaps I found reviewing Codex’s output and turned each one into a rule, not a project-specific fix, but a transferable principle a senior Spring developer would hold regardless of the task.

~/.codex/skills/senior-spring-developer/SKILL.md
---
name: senior-spring-developer
description: Use when designing or extending any Spring Boot service or API.
---

# Always follow these principles

## Configuration
- Use the latest stable versions that are mutually compatible across Java, Spring Boot, and all dependencies used.
- Prefer Spring's autoconfiguration and starters. Favor convention over configuration. Write custom configuration only when the convention doesn't fit.
- Explain any decision not to use the latest stable version.
- Create separate configuration per environment. Defaults must be safe, and a missing value should never silently fall back to another environment's settings.

## Implementation
- Keep the project structure simple.
- Never expose sensitive data in logs or API responses.
- Prevent unauthorized access to user-specific data, like personal data, preferences, or configuration.  
- A domain object should explain itself. The reader should understand the data model without reading the implementation.
- Enforce YAGNI. Use abstraction to cut boilerplate and improve readability, but never add abstraction for a future benefit that doesn't exist yet.

  
## Testing
- Always ensure actual behavior is tested also with integration tests against a real data store if possible.
- No external scripts should be required outside of running the application or tests.

## Finally
- If you break any of these principles, explain why.

In this run I used the prompt from second run, and I intentionally kept it loose. It’s the kind of vague user story a senior developer receives and turns into a simple solution without needing every detail spelled out.

Prompt Result

Codex CLI produced 23 files in 14 minutes and 21 seconds with 95.37% code coverage.

Tech StackCodex CLI versionLatest LTS
Java version2525
spring-boot-starter-parent4.1.04.1.0
software.amazon.awssdk.bom2.46.102.46.10
DynamoDBLocal2.6.13.3.0

Dependencies:

  • spring-boot-starter-validation
  • spring-boot-starter-web
  • spring-boot-starter-security
  • spring-boot-starter-test
  • spring-security-test
  • software.amazon.awssdk.dynamodb
  • com.amazonaws.DynamoDBLocal

Codex CLI chose the older DynamoDBLocal from com.amazonaws artifact instead of the newer DynamoDBLocal software.amazon.dynamodb artifact. Codex CLI ignored the skill’s rule to use the latest compatible version. In my Simple Docker-free Setup blog post I used version 3.3.0 without any issues. You can check out the code on GitHub.

I’ve confirmed the current stable releases from Maven Central: Spring Boot 4.1.0, AWS SDK 2.46.10, DynamoDBLocal 2.6.1, and Spring Java Format 0.0.47.

Two smaller gaps: there’s no .gitignore, and the Maven wrapper setup is incomplete.

Architecture Review

Project Structure ✅

Project uses package by layer structure. The DuplicateProductException is placed in the repository layer, where it is thrown in case of duplicate entries. Exception is then translated to HTTP response in the exception handler. No extra interfaces.

Plaintext
src/main/
  ├── java/
  │   └── com/
  │       └── example/
  │           └── cart/
  │               ├── ShoppingCartApplication.java
  │               ├── config/
  │               │   ├── CartSecurityProperties.java
  │               │   ├── DynamoDbConfiguration.java
  │               │   ├── DynamoDbProperties.java
  │               │   └── SecurityConfiguration.java
  │               ├── domain/
  │               │   └── CartProduct.java
  │               ├── persistence/
  │               │   ├── DuplicateProductException.java
  │               │   └── DynamoDbCartRepository.java
  │               ├── service/
  │               │   └── ShoppingCartService.java
  │               └── web/
  │                   ├── AddProductRequest.java
  │                   ├── ApiExceptionHandler.java
  │                   ├── CartResponse.java
  │                   ├── ProductResponse.java
  │                   └── ShoppingCartController.java
  └── resources/
      ├── application-local.yml
      └── application.yml

Data Model ✅

Composite key was again used without being told.

Code Coverage ✅

Lines covered exceeded the prompt requirement.

Profiles for different execution ✅

Separate profile for local, test and production.

DynamoDB Local ⚠️

DynamoDBLocal is now a Maven dependency rather than a downloaded binary, and for the integration tests it starts automatically as part of the test lifecycle, exactly what the skill asked for. Local development is where it falls short. Readme section tells us to run the following script:

Bash
./mvnw test-compile dependency:copy-dependencies \
  -DincludeArtifactIds=libsqlite4java-osx,libsqlite4java-linux-amd64 \
  -DoutputDirectory=target/native-libs \
  exec:java -Dexec.mainClass=com.amazonaws.services.dynamodbv2.local.main.DynamoDBLocal \
  -Dexec.classpathScope=test -Dexec.args="-inMemory -sharedDb -disableTelemetry -port 8000" \
  -Dsqlite4java.library.path=target/native-libs

The script fails to run as we are missing the .mvn directory where the Maven wrapper is located. Even after restoring it from another project the compilation fails:

Plaintext
An exception occurred while executing the Java class. com.amazonaws.services.dynamodbv2.local.main.DynamoDBLocal

The table creation is handled in DynamoDbConfiguration, where it leverages the local endpoint override.

Security ✅

Thanks to the skill, it identified the need to authenticate the request. The principal is then used as a partition key, rather than trusting a userId passed in the path. This is the exact flaw that failed in both previous runs.

SecurityConfiguration.java
@Configuration
class SecurityConfiguration {

	@Bean
	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		return http.csrf(csrf -> csrf.disable())
			.authorizeHttpRequests(requests -> requests.anyRequest().authenticated())
			.httpBasic(Customizer.withDefaults())
			.build();
	}

	@Bean
	PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}

	@Bean
	UserDetailsService userDetailsService(CartSecurityProperties properties, PasswordEncoder passwordEncoder) {
		var user = User.withUsername(properties.username())
			.password(passwordEncoder.encode(properties.password()))
			.roles("CUSTOMER")
			.build();
		return new InMemoryUserDetailsManager(user);
	}

}

Code Review

Repository ⚠️

For the third time Codex CLI went with the low-level SDK to implement the repository logic which causes a lot of boilerplate. The low-level SDK requires mapping Java objects to AttributeValue that is used to pass values to DynamoDbClient. One improvement over the earlier runs is the use of queryPaginator, a variant of query(QueryRequest) that returns an iterable handling pagination automatically, so the SDK makes the follow-up calls instead of the code paging by hand.

It’s a better use of the low-level API, but the enhanced client offers the same automatic pagination out of the box, and returns mapped domain objects instead of raw attribute maps. So the improvement doesn’t justify staying low-level. The abstraction the skill asked for would have given the same pagination with none of the mapping.

Java
public List<CartProduct> findAll(String userId) {
		var products = new ArrayList<CartProduct>();
		var request = QueryRequest.builder()
			.tableName(tableName)
			.keyConditionExpression("#userId = :userId")
			.expressionAttributeNames(Map.of("#userId", USER_ID))
			.expressionAttributeValues(Map.of(":userId", stringValue(userId)))
			.build();
		client.queryPaginator(request).items().forEach(item -> products.add(toProduct(item)));
		return List.copyOf(products);
}

public void add(String userId, CartProduct product) {
		var item = Map.of(USER_ID, stringValue(userId), PRODUCT_ID, stringValue(product.productId()), UNIT_PRICE,
				numberValue(product.unitPrice()));
		try {
			client.putItem(PutItemRequest.builder()
				.tableName(tableName)
				.item(item)
				.conditionExpression("attribute_not_exists(#userId) AND attribute_not_exists(#productId)")
				.expressionAttributeNames(Map.of("#userId", USER_ID, "#productId", PRODUCT_ID))
				.build());
		}
		catch (ConditionalCheckFailedException ex) {
			throw new DuplicateProductException(product.productId());
		}
}

public void removeAll(String userId) {
		findAll(userId).forEach(product -> remove(userId, product.productId()));
}

private static AttributeValue stringValue(String value) {
		return AttributeValue.builder().s(value).build();
}

private static AttributeValue numberValue(BigDecimal value) {
		return AttributeValue.builder().n(value.toPlainString()).build();
}

No interface abstraction which keeps this implementation very simple. The ConditionalCheckFailedException is translated into DuplicateProductException, which is placed in the same package. During cart removal, removeAll calls findAll without a strongly consistent read, so a recently added item could be missing from the result and survive the delete.

Reasoning about batch write is a recurring theme:

I’m considering the setup for storing product information. It seems better to have one item per product with a user partition and product sort key. The user mentioned they want user ID as the partition key, but that doesn’t exclude using a sort key for product ID. I’ve got a whole delete query and batch in place, and I just need to add conditional Put. I’ll also integrate the entire cart query and batch write, keeping the chunks to a maximum of 25. Good plan!

Refining delete method

I’m implementing a method that needs to keep trying until unprocessed items are handled. Each response gets defaults, but I might run into infinite loops under throttling, which isn’t great. AWS recommends using a backoff strategy, so I’m considering adding a maximum number of attempts—maybe three tries before throwing an exception. However, it looks like the test doesn’t cover this branch yet. A simpler approach might be to delete items individually, which avoids batch issues and improves line coverage. I need to remove unnecessary batch imports for better clarity.

Domain Object ⚠️

The CartProduct was again designed as a Java record with 2 attributes, productId and unitPrice, providing very little information about the entity.

Prevent Duplicates ✅

Duplicates handled correctly, it’s not possible to insert the new product twice. Not a real life scenario, just a simple validation use case.

Controller ✅

There is a big improvement in the controller as security is now properly implemented. User id cannot be passed as a path variable, validation and HTTP Methods are correctly implemented.

Tests ✅

Tests are reduced to 2 classes. Service test mocks the repository layers and asserts the return values. It is a simple unit test. The other class is an integration test annotated with @SpringBootTest, it tests the unauthorized access, bad request and a success path. Test automatically starts DynamoDBLocal and proceeds with the real datastore. RestClient instance is manually constructed introducing a need to define the base path and application port configured by spring boot integration test.

Java
private RestClient authenticatedClient() {
		return RestClient.builder()
			.baseUrl("http://localhost:" + applicationPort)
			.defaultHeaders(headers -> headers.setBasicAuth("test-user", "test-password"))
			.build();
}

There is very little information about this decision:

Spring Boot 4 removed TestRestTemplate from the default test starter, and DynamoDBLocal moved ServerRunner into its main package. I’m adapting the integration test to use Spring’s RestClient and the current DynamoDBLocal API, then I’ll rerun the full build.

Senior developer would most probably introduce the spring-boot-resttestclient Maven dependency, annotate the test with @AutoConfigureRestTestClient and inject RestTestClient, but the test does what matters — real datastore, real auth, meaningful paths — so it passes.

Third prompt conclusion

Summary
8
⚠️3
0

For the first time, nothing failed. The security flaw that broke both earlier runs is gone, because Codex CLI added authentication and scoped the cart to the principal. It did so because the skill told it to care about unauthorized access, not because the prompt described the solution. The abstraction problem resolved, profiles cover three environments, and the integration test runs against a real DynamoDB that starts itself.

But what the skill didn’t fix is just as telling: the repository still uses the low-level SDK for the third time, the domain object is still a bare record, removeAll still reads without strong consistency, and Codex CLI quietly chose a legacy DynamoDBLocal, without explanation, missing both the rule to use the latest compatible version and the rule to explain when it doesn’t. That last choice has a real consequence, as the local setup doesn’t actually run. The skill could only fix what it addressed.

4. Extra High Reasoning

The prompt was executed using Codex CLI with the GPT-5.5 model and extra high reasoning. Since the third run surprised me with how far it got, I kept both the skill and the prompt unchanged.

Prompt Result

Codex CLI produced 30 files in 13 minutes and 09 seconds with 94% code coverage.

Tech StackCodex CLI versionLatest LTS
Java version2525
spring-boot-starter-parent4.1.04.1.0
software.amazon.awssdk.bom2.46.132.46.13
DynamoDBLocal2.6.13.3.0

Dependencies:

  • spring-boot-starter-validation
  • spring-boot-starter-web
  • spring-boot-configuration-processor
  • spring-boot-starter-test
  • spring-security-test
  • software.amazon.awssdk.dynamodb
  • software.amazon.awssdk.url-connection-client
  • software.amazon.awssdk.auth
  • com.amazonaws.DynamoDBLocal

As previously Codex CLI chose the older DynamoDBLocal from com.amazonaws.

Refining dependencies

I’m considering whether to leave some dependencies for security reasons. The Spring Boot dependency management may update to safer versions, but I need to be careful about direct dependencies like DDBLocal POM version 2.17.1 causing conflicts. Boot 4 manages log4j, and I’m pondering if I should exclude log4j-core; perhaps it’s not necessary. I also noted that I won’t be using the dynamodb-enhanced dependency directly. For testing, I’ll include spring-bootstarter-test and ensure DynamoDBLocal is available for both compile and test scopes.

This already somehow sets the path on what can we expect in this chapter. You can check out the source on GitHub.

Architecture Review

Project Structure ⚠️

With extra high reasoning we are back with a interface with 1 implementation ShoppingCartRepository. I don’t consider this a pass just because it contradicts the skill without explaining the reason.

Plaintext
src/main/
  ├── java/com/example/cart/
  │   ├── ShoppingCartApplication.java
  │   ├── config/
  │   │   ├── DynamoDbClientConfiguration.java
  │   │   ├── DynamoDbLocalConfiguration.java
  │   │   ├── DynamoDbProperties.java
  │   │   └── DynamoDbTableInitializer.java
  │   ├── domain/
  │   │   ├── CartItem.java
  │   │   └── ShoppingCart.java
  │   ├── repository/                                                                                                                                                                             
  │   │   ├── DuplicateProductException.java
  │   │   ├── DynamoDbShoppingCartRepository.java
  │   │   └── ShoppingCartRepository.java
  │   ├── service/
  │   │   └── ShoppingCartService.java
  │   └── web/
  │       ├── AddCartItemRequest.java
  │       ├── ApiExceptionHandler.java                                                                                                                                                            
  │       ├── CartItemResponse.java
  │       ├── ShoppingCartController.java
  │       └── ShoppingCartResponse.java
  └── resources/
      ├── application.yml                                                                                                                                                                         
      └── application-local.yml

Data Model ✅

Composite key was again used without being told.

Code Coverage ✅

Lines covered exceeded the prompt requirement.

Profiles for different execution ✅

Separate profile for local, test and production.

DynamoDB Local ✅

For the first time in four runs, this is handled the way I wanted. Codex CLI registered a DynamoDBProxyServer bean with initMethod = "start" and destroyMethod = "stop", so DynamoDB Local starts with the application context and shuts down with it. No script to run by hand, the lifecycle managed by Spring itself. ServerRunner launches an actual local server. In an earlier post I used DynamoDBEmbedded, which runs in-process instead, a simpler option that would also have worked here.

The rough edge shows up in a “normal” workflow. The native libraries are copied into target/native-libs during the Maven build, but mvn clean wipes that directory, and launching spring-boot:run from the IDE doesn’t re-trigger the copy. So a clean followed by an IDE run fails on startup. The error message is clear and the fix is one Maven command, and I’m not going to nitpick about that.

DynamoDbLocalConfiguration.java
@Profile("local")
@Configuration(proxyBeanMethods = false)
class DynamoDbLocalConfiguration {

	@Bean(initMethod = "start", destroyMethod = "stop")
	DynamoDBProxyServer dynamoDbLocalServer(DynamoDbProperties properties) throws Exception {
		URI endpoint = properties.endpoint();
		if (endpoint == null || endpoint.getPort() < 1) {
			throw new IllegalStateException("The local profile requires cart.dynamodb.endpoint with an explicit port.");
		}
		Path nativeLibraries = Path.of("target", "native-libs").toAbsolutePath();
		if (!Files.isDirectory(nativeLibraries)) {
			throw new IllegalStateException("DynamoDBLocal native libraries were not found at " + nativeLibraries
					+ ". Run Maven so process-resources can copy them.");
		}
		System.setProperty("sqlite4java.library.path", nativeLibraries.toString());
		return ServerRunner.createServerFromCommandLineArgs(
				new String[] { "-inMemory", "-port", Integer.toString(endpoint.getPort()) });
	}

}

Security ❌

The controller exposes shopping cart information for any user simply by passing the X-User-Id header. It is assumed that it was set by a trusted client, but in reality any caller can impersonate any user by setting this header.

Code Review

Repository ⚠️

After four runs, I think we can finally conclude that Codex CLI has no intention of using the DynamoDB Enhanced Client unless it’s probably forced to:

I also noted that I won’t be using the dynamodb-enhanced dependency directly.

What bothers me more, is the unexplained return of the ShoppingCartRepository interface, that I thought we got rid of. I have already explained my reasons here. Extra high reasoning brought it back.

Domain Object ⚠️

CartItem and ShoppingCart have their own rules defined. CartItem validates product and price in its constructor. It’s a second validation after the API validation.

CartItem.java
public record CartItem(String productId, BigDecimal unitPrice) {

	  public CartItem {
		    if (!StringUtils.hasText(productId)) {
			      throw new IllegalArgumentException("productId must not be blank");
		    }
		    if (unitPrice == null || unitPrice.compareTo(BigDecimal.ZERO) <= 0) {
			      throw new IllegalArgumentException("unitPrice must be greater than zero");
		    }
		    try {
			      unitPrice = unitPrice.setScale(2, RoundingMode.UNNECESSARY);
		    }
		    catch (ArithmeticException ex) {
			      throw new IllegalArgumentException("unitPrice must use at most two decimal places", ex);
		    }
		    productId = productId.trim();
    }

}

ShoppingCart validates the userId, and it basically serves the same purpose as the response object in second run. Using the same record as both domain object and API response works here, but in a real system the response and the domain model usually diverge. It also stays a read-only wrapper partly because of the low-level SDK. The repository maps raw attributes manually, so the domain model never becomes the persistence object the way a senior developer would design it with the enhanced client.

ShoppingCart.java
public record ShoppingCart(String userId, List<CartItem> items) {

    public ShoppingCart {
		    if (!StringUtils.hasText(userId)) {
			      throw new IllegalArgumentException("userId must not be blank");
		    }
		    userId = userId.trim();
		    items = List.copyOf(items);
    }

	  public BigDecimal totalPrice() {
		    return this.items.stream().map(CartItem::unitPrice).reduce(BigDecimal.ZERO, BigDecimal::add);
	  }

}

Prevent Duplicates ✅

Duplicates handled correctly, it’s not possible to insert the new product twice. Not a real life scenario, just a simple validation use case.

Controller ⚠️

REST design and validation are solid, but all endpoints allow reading and modifying the shopping cart simply by passing userId in a header. Controller doesn’t validate the caller.

Java
private static final String USER_ID_HEADER = "X-User-Id";

@GetMapping
ShoppingCartResponse getCart(@RequestHeader(USER_ID_HEADER) @NotBlank String userId) {
		return ShoppingCartResponse.from(this.shoppingCartService.getCart(normalize(userId)));
}

@DeleteMapping
ResponseEntity<Void> removeCart(@RequestHeader(USER_ID_HEADER) @NotBlank String userId) {
		this.shoppingCartService.removeCart(normalize(userId));
		return ResponseEntity.noContent().build();
}

Tests ✅

Test coverage is above the expected 85%. Integration Tests are executed against a real DynamoDBLocal database. As in the third run, it constructs the RestClient instance manually, instead of using the auto-configuration provided by Spring Boot 4.

Fourth prompt conclusion

Summary
6
⚠️4
1

Run three and run four used the identical skill and identical prompt, so reasoning effort was the only variable. Run three got security right and stayed interface-free. Run four fixed the DynamoDB Local lifecycle run three never solved. It starts and stops with the Spring context instead of requiring a hand-run script, which alone makes this run more usable out of the box than run three, where the local setup didn’t even compile. But “extra high reasoning” didn’t mean it followed the skill more closely. Security swapped the basic auth flow, for an X-User-Id header. And the single-implementation repository interface the skill explicitly rules out came back with no explanation.

Final Verdict

Can Codex CLI write senior-level code? My answer for now is NO. Not from this example. Codex CLI got most of the architecture right without help. But it only handled security and real integration testing after I explained why they mattered, and even then, not consistently.

I would not say it ignored instructions. A loose preference got partial results. A specific rule with a reason worked better, most of the time. But four runs on one small microservice is still a small sample, so I would treat this as a pattern to test, not a conclusion.

Twice now, the best-guided run still picked a legacy dependency, with no explanation, despite a rule asking for one. Neither run is the senior-level result on its own, and that’s the real finding here. Turning up reasoning doesn’t reliably converge toward compliance with the rules I gave it.

You can find all 4 projects on GitHub. One more note: the version numbers were current when I ran this. They will change, so check them before relying on them.

Subscribe to my newsletter




Latest Posts


Leave a Reply

Your email address will not be published. Required fields are marked *