Spring Boot + Neo4j: Building Social Graphs with Modeling, Queries & Performance Optimization
This article demonstrates how to integrate Spring Boot with Neo4j to build a social relationship graph, covering graph data modeling, Cypher query patterns for friend recommendations and shortest paths, performance optimization techniques including indexing and query profiling, and a complete demo implementation for a social feed.
Why Graph Databases for Social Applications
Relational databases struggle with deep relationship queries: finding "friends of friends" requires multiple JOINs, and shortest-path queries need recursive CTEs or application-level loops. As data grows, SQL becomes painful to write and slow to execute.
Graph databases treat relationships as first-class citizens. Nodes represent entities (User, Post), edges represent relationships (FRIEND_OF, FOLLOWS, PUBLISHED, LIKES). Traversing edges is a pointer chase, not a JOIN, so query cost depends only on the explored subgraph, not the total graph size.
Graph Data Modeling for a Twitter-like Platform
Core Entities and Relationships
Two node types: User (userId, name, age, city) and Post (postId, content, createTime, likesCount). Four relationship types: (User)-[:FRIEND_OF]-(User) – bidirectional friendship (User)-[:FOLLOWS]->(User) – directional follow (follower → followee) (User)-[:PUBLISHED]->(Post) – user publishes post (User)-[:LIKES]->(Post) – user likes post
Modeling Best Practices
Don't over-create nodes. In Neo4j, edges can carry properties. For example, a LIKES edge can have a time property instead of creating a separate Like node. This differs from relational thinking where join tables hold extra columns.
Relationship properties illustrated:
FRIEND_OF: since (timestamp), level (intimacy)
FOLLOWS: createdAt LIKES: time These properties later enable recommendation ranking.
Relationship Direction Conventions
FOLLOWS direction: follower → followee. Querying followees uses OUTGOING; querying followers uses INCOMING. Document this convention to avoid inconsistencies.
FRIEND_OF is undirected. Store a single edge and query with UNDIRECTED (Spring Data Neo4j's Direction.UNDIRECTED). Creating two opposite edges risks "ghost relationships" if maintenance fails.
Spring Boot Integration with Neo4j
Dependencies and Configuration
Add Maven dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-neo4j</artifactId>
</dependency>Configuration (Spring Boot 3.x uses spring.neo4j.* prefix; 2.x used spring.data.neo4j.*):
spring:
neo4j:
uri: bolt://localhost:7687
username: neo4j
password: password
database: socialNote: Protocol is bolt://, not http://.
Entity Definitions
Annotations resemble JPA but with graph semantics. Example User entity:
@Node("User")
public class User {
@Id @GeneratedValue
private Long id; // Neo4j internal ID
@Property("userId") @Unique
private String userId; // business ID
@Property("name") private String name;
@Property("age") private Integer age;
@Property("city") @Index private String city;
@Relationship(type = "FRIEND_OF", direction = Relationship.Direction.UNDIRECTED)
private Set<User> friends;
@Relationship(type = "FOLLOWS", direction = Relationship.Direction.OUTGOING)
private Set<User> following;
@Relationship(type = "FOLLOWS", direction = Relationship.Direction.INCOMING)
private Set<User> followers;
@Relationship(type = "PUBLISHED", direction = Relationship.Direction.OUTGOING)
private Set<Post> posts;
@Relationship(type = "LIKES", direction = Relationship.Direction.OUTGOING)
private Set<Post> likedPosts;
}Key detail: @Id maps to Neo4j's internal Long ID (auto-generated). Business ID userId is a regular @Property with @Unique constraint.
Relationship Entities with Properties
When relationships need properties, define a class annotated with @RelationshipProperties and use @TargetNode:
@RelationshipProperties
public class Friendship {
@RelationshipId private Long id;
@Property("since") private LocalDateTime since;
@Property("level") private Integer level;
@TargetNode private User friend;
}Then in User:
@Relationship(type = "FRIEND_OF", direction = Relationship.Direction.UNDIRECTED) private List<Friendship> friendships;Version note: Spring Data Neo4j 6.x replaced @RelationshipEntity, @StartNode, @EndNode with @RelationshipProperties and @TargetNode.
Repository Layer
Neo4jRepository<T, ID>works like JpaRepository. Custom queries use @Query with Cypher:
public interface UserRepository extends Neo4jRepository<User, Long> {
Optional<User> findByUserId(String userId);
@Query("MATCH (u:User) WHERE u.city = $city RETURN u")
List<User> findByCity(@Param("city") String city);
}Save Semantics and Performance
save()performs deep persistence: it compares the entire object graph with the database and applies incremental updates. Example:
User user = userRepository.findByUserId(userId).orElseThrow();
User target = userRepository.findByUserId(targetUserId).orElseThrow();
user.getFollowing().add(target);
userRepository.save(user); // creates FOLLOWS relationshipHowever, for single relationship additions, a direct Cypher MERGE is lighter and idempotent:
@Modifying
@Query("MATCH (a:User {userId: $userId}), (b:User {userId: $targetId}) MERGE (a)-[:FOLLOWS]->(b)")
void addFollowing(@Param("userId") String userId, @Param("targetId") String targetId);Cypher Query Patterns for Social Features
Friend-of-Friend Recommendation
MATCH (me:User {userId: $userId})-[:FRIEND_OF]->(friend:User)-[:FRIEND_OF]->(candidate:User)
WHERE NOT (me)-[:FRIEND_OF]->(candidate) AND candidate <> me
RETURN candidate, count(*) AS mutualCount
ORDER BY mutualCount DESC
LIMIT 10Extending to three degrees adds another hop in the MATCH pattern.
Common Follows Analysis
MATCH (u1:User {userId: $userId1})-[:FOLLOWS]->(common:User)<-[:FOLLOWS]-(u2:User {userId: $userId2})
RETURN commonShortest Path Between Users
MATCH (start:User {userId: $startId}), (end:User {userId: $endId}),
path = shortestPath((start)-[:FRIEND_OF|FOLLOWS*..6]-(end))
RETURN [n IN nodes(path) | n.userId] AS userIds, length(path) AS depthCritical: *..6 limits max depth to 6; unbounded paths ( *) can traverse the entire graph. Multiple relationship types allowed in the pattern.
Feed Retrieval (Posts from Followees)
MATCH (me:User {userId: $userId})-[:FOLLOWS]->(followee:User)-[:PUBLISHED]->(post:Post)
WHERE NOT (me)-[:BLOCKED]-(followee)
RETURN post, followee.name AS author
ORDER BY post.createTime DESC
LIMIT $limitThe NOT (me)-[:BLOCKED]-(followee) filter excludes blocked users without a LEFT JOIN.
Performance Optimization
Why Graph Traversals Are Fast
Neo4j uses index-free adjacency : each node stores direct references to its adjacent edges. Traversal follows pointers, so cost is proportional to visited nodes, not total graph size. This makes deep relationship queries stable even as the graph grows.
However, graph databases are not universally faster. Pure aggregation queries (e.g., "count users per city") are OLAP workloads where relational databases excel.
Indexing Strategy
Traversals don't need indexes, but finding the start node does. Essential indexes:
Unique constraint on User.userId Index on User.name if searched by name
Index on Post.createTime for time-range queries
Index on User.city for location filters
Define via annotations ( @Unique, @Index) or explicit Cypher:
CREATE CONSTRAINT unique_user_id IF NOT EXISTS FOR (n:User) REQUIRE n.userId IS UNIQUE;
CREATE INDEX user_city_index IF NOT EXISTS FOR (n:User) ON (n.city);
CREATE INDEX post_createTime_index IF NOT EXISTS FOR (n:Post) ON (n.createTime);Query Design Pitfalls
Avoid unbounded paths. Always specify an upper bound (e.g., *..6).
Use PROFILE. Prefix queries with PROFILE in Neo4j Browser to see row counts and DB hits per operator. Often reveals scans orders of magnitude larger than expected.
Application-Level Caching
Spring Cache can cache results like common-follows analysis:
@Cacheable(value = "commonFollowing", key = "#userId1 + ':' + #userId2")
public List<User> getCommonFollowing(String userId1, String userId2) {
return userRepository.findCommonFollowing(userId1, userId2);
}TTL-based expiration (e.g., 5 minutes) is simple; precise invalidation requires evicting on follow/unfollow events.
Bulk Write Performance
Don't call save() in a loop. Use UNWIND for batch relationship creation:
UNWIND $batch as row
MATCH (u:User {userId: row.userId})
MATCH (f:User {userId: row.followsUserId})
MERGE (u)-[:FOLLOWS]->(f)For initial loads of millions of records, use neo4j-admin import (offline bulk loader), which is orders of magnitude faster than API writes.
Demo: Social Feed Endpoint
Repository
public interface PostRepository extends Neo4jRepository<Post, Long> {
@Query("MATCH (me:User {userId: $userId})-[:FOLLOWS]->(followee:User)-[:PUBLISHED]->(post:Post) " +
"WHERE post.createTime >= $since " +
"RETURN post, followee.name AS author " +
"ORDER BY post.createTime DESC LIMIT $limit")
List<Map<String, Object>> findFeedByUser(
@Param("userId") String userId,
@Param("since") LocalDateTime since,
@Param("limit") int limit);
}Service
@Service
public class FeedService {
private final PostRepository postRepository;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public FeedResult getFeed(String userId) {
LocalDateTime since = LocalDateTime.now().minusDays(7);
List<Map<String, Object>> feed = postRepository.findFeedByUser(userId, since, 50);
List<Map<String, Object>> suggestions = userRepository.findFriendSuggestions(userId, 10);
FeedResult result = new FeedResult();
result.setFeed(feed);
result.setSuggestedUsers(suggestions);
return result;
}
}Controller
@RestController
@RequestMapping("/api/social")
public class SocialController {
private final FeedService feedService;
@GetMapping("/feed/{userId}")
public FeedResult getFeed(@PathVariable String userId) {
return feedService.getFeed(userId);
}
}Calling /api/social/feed/Alice returns posts from Alice's followees (Bob, Carol) ordered by time, plus friend suggestions (e.g., Dave with 2 common follows).
When Not to Use a Graph Database
Pure attribute aggregation (e.g., "count 30-year-old users in Beijing") – SQL GROUP BY is faster.
Highly regular, fixed-schema data (e.g., e-commerce orders) – relational model is simpler.
Billion-node scale – Neo4j Community Edition is single-node; distributed graph databases add operational complexity.
A pragmatic hybrid architecture: MySQL for core transactions, Neo4j for relationship-depth analysis, synchronized via message queues.
Spring Data Neo4j eliminates most boilerplate; developers familiar with JPA transition easily. Cypher is approachable – half a day of documentation and examples suffices. Best practice: start with a real query requirement and iterate.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Xiaolin Talks Programming
Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
