AWS Builder Center
Building the Money Layer of Straw2Cash: Java + DynamoDB on ECS Fargate

Building the Money Layer of Straw2Cash: Java + DynamoDB on ECS Fargate

How I architected the financial backbone of a crop-residue marketplace — table-driven pricing, contract state machines, idempotent settlements, and one-command AWS deployments.

Building the Money Layer: My Contribution to Straw2Cash on AWS

This is a companion piece to the team's overview of Straw2Cash. While the team blog focuses on the marketplace architecture as a whole, this post goes deep on the part I owned: the Java backend that handles pricing, contracts, settlements, authentication, and impact reporting.



1. Why I Chose Java for the Money Layer

When I agreed to own the money layer of Straw2Cash, I had a choice to make: which language, which framework, which architectural pattern?
Three things drove the decision.
First, precision. Farmers in Punjab don't need their income to drift by a paisa every transaction. Neither do the buyers, transporters, or the platform itself. Floating-point arithmetic loses money — literally. In Java,  BigDecimal  gives us exact decimal arithmetic. In Python,  0.1 + 0.2  returns  0.30000000000000004 . In Java's  BigDecimal , it returns  0.3 . For a financial system, that difference isn't cosmetic.
Second, auditability. Spring Boot is boring. That's a feature. When a payment system needs to be reviewed — by an auditor, by a regulator, or by a teammate at 2 AM — nobody wants to reverse-engineer a novel framework. Standard patterns make verification fast. Boring tech wins in fintech.
Third, predictability. Batch jobs, scheduled settlements, idempotent retries — these all have well-understood patterns in Java. I didn't want to invent anything new. I wanted to compose known-good pieces into a system that behaves predictably under load, retries, and partial failures.
The stack: Java 21 bytecode, Spring Boot 3.4.1, Spring Security 6.4, AWS SDK v2 with the DynamoDB Enhanced Client, containerized with Docker, deployed on AWS ECS Fargate.



2. The Five Things My Service Owns

While the Go service handles matching and Python handles data processing, my Java service owns five responsibilities that make the marketplace financially real:
Responsibility
What it answers
Pricing Engine
What is this residue worth?
Contract Service
Who agreed to what, and when?
Settlement Service
How does the money actually move?
Auth & Role Management
Who can do what?
Impact Reporting
How much pollution did we prevent?
Every one of these is exposed as a REST endpoint. Every state change is a DynamoDB write. Every rupee uses  BigDecimal .
Let me walk through each.



3. The Pricing Engine: Rules in Data, Not in Code

The pricing logic lives in a DynamoDB table called  price_rules , not in Java code. This was a deliberate choice.
Why table-driven pricing matters. Business rules change faster than code. If a buyer negotiates a different rate for high-moisture straw, or a new buyer type enters the market, we shouldn't need to redeploy. The pricing rules are data — they can be updated without touching a single line of Java.
The formula:
Plain text
pricePerTonne = baseRate
− moisturePenalty
+ gradeAdjustment

grossAmount = pricePerTonne × tonnes
transportCost = distanceKm × ratePerKm × tonnes
platformFee = grossAmount × 6%
buyerPays = grossAmount + transportCost + platformFee
netToFarmer = grossAmount
One design decision I want to highlight: Transport cost is charged to the buyer as a separate line item — it is not deducted from the farmer's earnings. This keeps the "farmer income" story unambiguous. The farmer's net is exactly  pricePerTonne × tonnes . When we show impact numbers later, every rupee is accounted for.
Seeded rules in DynamoDB:
Buyer Type
Base (₹/t)
Distance (₹/km/t)
Moisture Threshold
Moisture Penalty
Grade A
Grade B
Grade C
BIOMASS_POWER_PLANT
2500
45
14%
₹80/t per %
+0
−100
−250
BRICK_KILN
1800
40
16%
₹70/t per %
+0
−80
−200
PAPER_MILL
2200
50
12%
₹90/t per %
+50
−100
−250
CATTLE_FEED_MAKER
3000
55
18%
₹60/t per %
+100
−100
−300
Worked example — biomass power plant, 5.2 tonnes, 12% moisture, Grade A, 40 km away:
Plain text
baseRate = 2500
moisturePenalty = 0 (12% is at or below the 14% threshold)
gradeAdjustment = 0 (Grade A, no premium for this buyer type)
pricePerTonne = 2500

grossAmount = 2500 × 5.2 = 13,000
transportCost = 45 × 40 × 5.2 = 9,360
platformFee = 6% × 13,000 = 780
buyerPays = 13,000 + 9,360 + 780 = 23,140
netToFarmer = 13,000
What the frontend receives. The response includes an itemized  breakdown[]  array that the frontend renders line by line:
Plain text
[
{"label": "Base price (BIOMASS_POWER_PLANT)", "value": 2500},
{"label": "Moisture penalty (12.0%)", "value": 0},
{"label": "Grade A adjustment", "value": 0},
{"label": "Net price per tonne to farmer", "value": 2500.00},
{"label": "Transport cost (40 km)", "value": 9360.00},
{"label": "Platform fee (6%)", "value": 780.00}
]
When a judge asks "how did you get ₹13,000?" — the answer is right there. No black-box pricing.



4. The Contract State Machine

A contract in Straw2Cash moves through a strict state machine. There is no way to accidentally skip a step.
Plain text
[CREATE]
│
▼
PENDING_FARMER_SIGNATURE
│ farmer signs
▼
PENDING_BUYER_SIGNATURE
│ buyer signs
│ → SNS event fires
▼
ACTIVE
│ settle
▼
COMPLETED
Every transition is enforced in the service layer. The state field itself is the guard.
What this prevents:
  • Signing as buyer before the farmer has signed
  • Re-signing after a signature is already recorded
  • Settling a contract that hasn't been fully signed
  • Reusing a quote that already produced a contract
Each of these returns a specific error code —  CONTRACT_ERROR  — with a message that tells the client exactly what went wrong. The frontend can map these to localized messages.
Why this matters. Financial workflows have real consequences. If a contract can be settled without both signatures, or signed twice, or reused from a stale quote, you have a bug that costs money. The state machine closes these doors at the service layer. There is no path around them.
Query pattern. We index contracts by  farmerId  using a Global Secondary Index. A farmer can see all their contracts with a single query — no scanning.



5. Settlement: Idempotent by Design

This is the most important sentence in this post: calling the settlement endpoint twice returns the same record.
Here's the settlement money flow. The buyer pays 23,140 rupees. That amount splits three ways:
Plain text
BUYER (pays 23,140)
│
▼
BUYER_ESCROW
│
┌──────┼──────┐
▼ ▼ ▼
₹13,000 ₹9,360 ₹780
FARMER TRANSPORTER PLATFORM
Why idempotency matters. Network retries happen. Users double-click. Queues re-deliver. Browsers retry on timeout. If any of these events causes a second settlement to be created, the buyer's money leaves twice. That's a bug that destroys trust — not just for that transaction, but for the platform.
The fix is simple and unglamorous. Before creating a settlement, we check whether one already exists for the same contract:
Plain text
List<SettlementEntity> existing = settlementRepository.findByContractId(contractId);
if (!existing.isEmpty()) {
return toResponse(existing.get(0));
}
Calling settle on the same contract returns the same settlement ID with the same three flows. No double payment. No data corruption. No ambiguity.
A defensive balance check. Before writing a settlement record, we verify that the numbers add up:
Plain text
BigDecimal sum = toFarmer.add(toTransporter).add(toPlatform);
if (sum.compareTo(buyerPaid) != 0) {
throw new SettlementException("Settlement mismatch");
}
If the numbers don't sum to what the buyer paid, we refuse to create a bad record. This is a self-check against data corruption — a safety net that has caught bugs during development.
The simulation flag. Every settlement response includes  "simulated": true  and a disclaimer stating that no real money moves. This is deliberate. We're not pretending this is a production payment system. We're showing the architecture that would sit behind one.



6. Authentication and Role-Based Access

The auth system uses JWTs with four roles, and the roles matter because they enforce boundaries between what different participants can do.
Role
What they can do
FARMER
Price deals, sign as farmer, view own contracts
BUYER
Price deals, sign as buyer, settle payments
AUTHORITY
View regional impact dashboard
BALER_OPERATOR
(Handled by Go service — enum exists for consistency)
Role-based access matrix:
Endpoint
FARMER
BUYER
AUTHORITY
/api/v1/health
✅
✅
✅
/api/v1/pricing/quote
✅
✅
✅
/api/v1/contracts
✅
✅
✅
/api/v1/contracts/{id}/sign
✅
✅
✅
/api/v1/settlements
❌ 403
✅
❌ 403
/api/v1/impact/summary
❌ 403
❌ 403
✅
Enforcement happens at the Spring Security filter level.  POST /api/v1/settlements  has  hasRole("BUYER") .  GET /api/v1/impact/summary  has  hasRole("AUTHORITY") . Everything else requires authentication.
Phone as login ID. We chose phone numbers over email because the target users are farmers in rural India. Many don't have email. All have phones. Passwords are hashed with BCrypt.
JWT format is Cognito-compatible. The claims structure matches what AWS Cognito produces. Swapping to real Cognito is a config change, not a rewrite. We chose local JWT issuance for the hackathon to save setup time, but the architecture supports the swap.



7. Impact Reporting as a Policy Tool

The impact endpoint answers the questions that policymakers, CSR funders, and judges ask:
  • How much straw didn't get burned? → tonnes diverted
  • How much money reached farmers? → farmer income in rupees
  • How much CO₂ was avoided? → using a disclosed factor
  • How many deals closed? → contracts completed
The response includes the methodology. This is important. Judges should be able to see how we calculated 16.05 tonnes of CO₂ without digging through code. The factor — 1.5 tonnes CO₂e per tonne of paddy straw burned — is a commonly cited agricultural emission factor, and it's right there in the response:
Plain text
{
"tonnesDiverted": 10.70,
"farmerIncomeINR": 26760.00,
"co2AvoidedTonnes": 16.05,
"contractsCompleted": 3,
"methodologyNote": "CO2 avoided calculated using 1.5 t CO2e per tonne of paddy straw burned...",
"disclaimer": "SIMULATED FOR HACKATHON..."
}
This turns Straw2Cash into a policy tool, not just a marketplace. The numbers are defensible because the methodology is transparent.



8. Deploying to AWS

The Java backend is deployed on AWS ECS Fargate behind an Application Load Balancer with HTTPS via ACM.
Plain text
Internet
│
▼
ALB (HTTPS 443)
│
▼
ECS Fargate Task
(Spring Boot, port 8080)
│
▼
DynamoDB
(5 tables, 3 GSIs)
AWS services used:
Service
Purpose
Amazon ECS Fargate
Container compute — no EC2 instances to manage
Amazon ECR
Docker image registry
Application Load Balancer
Public HTTPS entry point with health checks
AWS Certificate Manager
SSL certificate (imported from ZeroSSL)
Amazon DynamoDB
5 tables, 3 GSIs, PAY_PER_REQUEST billing
Amazon CloudWatch
Structured logs with live streaming
AWS IAM
Separate roles for task execution vs. application runtime
Amazon SNS
Event notification when a contract activates
Cost: ~$1.50 per day when running, ~$0.50 per day when the ECS task is scaled to zero. Well within a hackathon budget.
Environment separation. Two Spring profiles:  local  uses DynamoDB Local with fake credentials;  aws  uses real DynamoDB with the ECS task role. The same code runs in both.
The credentials trap. We hit a bug that's worth documenting. Local development used hardcoded  "local"/"local"  credentials for DynamoDB Local. When we deployed to AWS, the same code tried to use those fake credentials against real DynamoDB, and we got  "The security token included in the request is invalid" . The fix: make the credentials provider conditional on whether a custom endpoint is configured.
Plain text
if (dynamoEndpoint != null && !dynamoEndpoint.isBlank()) {
// Local mode — fake credentials for DynamoDB Local
builder.endpointOverride(URI.create(dynamoEndpoint))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("local", "local")));
} else {
// AWS mode — DefaultCredentialsProvider picks up the ECS task role
builder.credentialsProvider(DefaultCredentialsProvider.create());
}
This is the kind of bug that only shows up in production. It's also the kind of bug that teaches you to be careful with credentials logic.



9. Deployment Automation

We built  ./scripts/deploy.sh  to handle the entire deployment pipeline in one command:
  1. Verify AWS credentials
  1. Log into ECR
  1. Build the Docker image
  1. Push to ECR
  1. Trigger ECS redeployment
  1. Wait for health check to pass
  1. Verify the API responds
One command → deployed backend in ~3 minutes. No manual steps. No forgetting to rebuild. No stale images.
We also have companion scripts:
  •  start.sh  — Spin up the ECS task in the morning
  •  stop.sh  — Kill it at night to save ~$1/day
  •  logs.sh  — Live tail CloudWatch logs
  •  status.sh  — Quick health check
Cost management is part of the design, not an afterthought.



10. Live Observability

I added a  RequestLoggingFilter  that logs every HTTP request with method, path, response status, and processing time:
Plain text
2026-10-11T06:15:22.123Z INFO GET /api/v1/health -> 200 (12ms)
2026-10-11T06:15:24.456Z INFO POST /api/v1/auth/login -> 200 (123ms)
2026-10-11T06:15:26.789Z INFO POST /api/v1/pricing/quote -> 200 (89ms)
Why this was a demo feature. During the hackathon, we opened two terminals side by side. One showed the live CloudWatch log tail. The other ran curl requests against the live API. Judges watched requests appear in the logs in real-time.
That's a "this is actually working" moment that no slide deck can replicate. It turns "I trust the team" into "I can see it working."



11. What Broke and What I Learned

DynamoDB GSIs can't be added to existing tables. We added an index to the initializer, but the initializer skips existing tables. Runtime error. The fix was to switch local DynamoDB to  -inMemory  mode — every restart wipes tables and rebuilds with correct schema. Lesson: treat DynamoDB schemas as code, not as ad-hoc changes.
CloudWatch logging permissions on the task execution role. The default ECS managed policy doesn't include  logs:CreateLogGroup . We had to add it explicitly. Lesson: managed policies cover common cases, but always verify.
Docker Hub TLS timeouts from Indian ISPs.  docker build  intermittently failed when pulling base images. Switching to  public.ecr.aws/docker/library/...  mirrors fixed it. Lesson: use AWS-hosted mirrors when reliability matters.
Spring Security blocking ACME challenge endpoints. Domain verification files at  /.well-known/pki-validation/...  returned 403 because Spring Security defaulted to  .anyRequest().authenticated() . We added  .requestMatchers("/.well-known/**").permitAll() . Lesson: public challenge endpoints should always be explicitly permitted.
The maven-shade-plugin I didn't need. We added it intending to build Lambda-compatible jars. Then realized we were deploying to ECS Fargate. The Spring Boot Maven plugin already builds runnable fat jars. Lesson: know your deployment target before adding build plugins.



12. What I'd Do Differently

Pre-aggregate impact numbers. Currently, the impact endpoint scans all settlements on every call. For a hackathon scale, this is fine. In production, I'd use DynamoDB Streams to update an  impact_counters  table in real-time. The impact call would then be O(1) instead of O(n).
Add a proper audit log. Right now, every state change is captured in the entity itself ( farmerSignedAt ,  buyerSignedAt ,  createdAt ). A dedicated append-only audit table would give us tamper-evident history. Not needed for the hackathon, but the architecture is set up for it.
Wire up Cognito properly. The JWT format is already Cognito-compatible. Swapping is a config change. But we chose local JWT for the hackathon. In production, I'd set up a Cognito User Pool with phone-based signup and MFA.
Add integration tests. We have none. JUnit with Testcontainers against DynamoDB Local would catch regressions before they hit production.



13. The Team Blog and This Post

The team's AWS Builder Center post covers the whole Straw2Cash architecture — Go for matching, Python for estimation and satellite data, Java for the money layer, plus the AWS services that support all of it.
This post is my piece of that. It's what I owned. It's what I built. It's what I'd defend in a code review.
If you're building a marketplace, a fintech app, or anything that handles money — I hope some of these patterns are useful. The biggest lessons are simple: use BigDecimal for money, make settlements idempotent, enforce contract state machines, disclose your methodology, and log every request.
Everything else follows from those.



14. Public Artifacts

Artifact
Link
Live API
 https://straw2cash.duckdns.org 
Team Blog
 https://builder.aws.com/content/3KY4uAu0q84k7u6xMVLdGuLA1Df/ 
GitHub Repo
github.com/DAbharat/Straw2Cash



15. Closing

Straw2Cash was an experiment in building production-grade financial infrastructure for a real-world problem in a hackathon timeframe.
The Go service handles matching. Python handles estimation and satellite data. The frontend makes it accessible in Hindi and Punjabi. And the Java layer makes it economically real.
Every rupee is computed with  BigDecimal . Every settlement is idempotent. Every contract transition is enforced. Every request is authenticated, authorized, and logged.
The mission: make selling crop residue more profitable than burning it. The Java backend makes that promise economically real.


If you're building fintech or money-handling systems, I'd love to hear what patterns you're using. Reach out — happy to talk shop.
 #AWS #Serverless #DynamoDB #SpringBoot #ECSFargate #AgriTech #Sustainability 
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article